+
canDrop(p.type, appId),
+ }}
+>
();
+
+export function registerEntity(descriptor: EntityDescriptor): void {
+ entities.set(descriptor.appId, descriptor);
+}
+
+export function getEntity(appId: string): EntityDescriptor | undefined {
+ return entities.get(appId);
+}
+
+export function getEntityByDragType(type: DragType): EntityDescriptor | undefined {
+ for (const e of entities.values()) {
+ if (e.dragType === type) return e;
+ }
+ return undefined;
+}
+
+export function canDrop(sourceType: DragType, targetAppId: string): boolean {
+ const target = entities.get(targetAppId);
+ if (!target?.acceptsDropFrom?.includes(sourceType)) return false;
+ if (!target.createItem) return false;
+ if (!target.transformIncoming?.[sourceType]) return false;
+ return true;
+}
+
+export async function executeDrop(
+ sourceItem: Record,
+ sourceAppId: string,
+ targetAppId: string
+): Promise {
+ const source = entities.get(sourceAppId);
+ const target = entities.get(targetAppId);
+ if (!source || !target)
+ throw new Error(`Entity not registered: ${sourceAppId} or ${targetAppId}`);
+ if (!target.createItem) throw new Error(`Target ${targetAppId} has no createItem`);
+
+ const transform = target.transformIncoming?.[source.dragType];
+ if (!transform) throw new Error(`No transform for ${source.dragType} → ${targetAppId}`);
+
+ // 1. Transform source data into target shape
+ const transformedData = transform(sourceItem);
+
+ // 2. Create new item in target module
+ const newItemId = await target.createItem(transformedData);
+
+ // 3. Build cached display data for link
+ const sourceDisplay = source.getDisplayData(sourceItem);
+ const targetDisplay = target.getDisplayData({ ...transformedData, id: newItemId });
+
+ const cachedSource = buildCachedData(sourceAppId, sourceDisplay.title, sourceDisplay.subtitle);
+ const cachedTarget = buildCachedData(targetAppId, targetDisplay.title, targetDisplay.subtitle);
+
+ // 4. Create bidirectional link
+ const { forward } = await linkMutations.createLink({
+ sourceApp: sourceAppId,
+ sourceCollection: source.collection,
+ sourceId: sourceItem.id as string,
+ targetApp: targetAppId,
+ targetCollection: target.collection,
+ targetId: newItemId,
+ linkType: 'related',
+ cachedSource,
+ cachedTarget,
+ });
+
+ return { newItemId, linkPairId: forward.pairId };
+}
+
+export function getAllEntities(): EntityDescriptor[] {
+ return Array.from(entities.values());
+}
diff --git a/apps/manacore/apps/web/src/lib/entities/types.ts b/apps/manacore/apps/web/src/lib/entities/types.ts
new file mode 100644
index 000000000..76fbf6430
--- /dev/null
+++ b/apps/manacore/apps/web/src/lib/entities/types.ts
@@ -0,0 +1,40 @@
+/**
+ * Entity Descriptor System — Types
+ *
+ * Each module declares an EntityDescriptor that describes how its items
+ * can be displayed, dragged, dropped, and created from other modules.
+ */
+
+import type { DragType } from '@manacore/shared-ui/dnd';
+
+export interface EntityDisplayData {
+ title: string;
+ subtitle?: string;
+}
+
+export interface EntityDescriptor {
+ appId: string;
+ collection: string;
+
+ /** How to display an item in link previews and drag ghosts. */
+ getDisplayData: (item: Record) => EntityDisplayData;
+
+ /** DragType used when dragging items from this module. */
+ dragType: DragType;
+
+ /** Which DragTypes this module accepts as drop targets. */
+ acceptsDropFrom?: DragType[];
+
+ /** Transform incoming items from other modules into this module's create shape. */
+ transformIncoming?: Partial<
+ Record) => Record>
+ >;
+
+ /** Create a new item in this module. Returns the new item's ID. */
+ createItem?: (data: Record) => Promise;
+}
+
+export interface DropResult {
+ newItemId: string;
+ linkPairId: string;
+}
diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/ListView.svelte
index dc8c962e3..4c9297780 100644
--- a/apps/manacore/apps/web/src/lib/modules/calendar/ListView.svelte
+++ b/apps/manacore/apps/web/src/lib/modules/calendar/ListView.svelte
@@ -10,7 +10,7 @@
import { eventsStore } from './stores/events.svelte';
import { Plus } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
- import { dropTarget } from '@manacore/shared-ui/dnd';
+ import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@@ -145,6 +145,17 @@
_siblingIds: todayEvents.map((e) => e.id),
_siblingKey: 'eventId',
})}
+ use:dragSource={{
+ type: 'event',
+ data: () => ({
+ id: event.id,
+ title: event.title,
+ startDate: event.startDate,
+ endDate: event.endDate,
+ description: event.description,
+ location: event.location,
+ }),
+ }}
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(event.id, p.data as unknown as TagDragData),
diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/entity.ts b/apps/manacore/apps/web/src/lib/modules/calendar/entity.ts
new file mode 100644
index 000000000..f73788c21
--- /dev/null
+++ b/apps/manacore/apps/web/src/lib/modules/calendar/entity.ts
@@ -0,0 +1,67 @@
+import { registerEntity } from '$lib/entities/registry';
+import { eventsStore } from './stores/events.svelte';
+import { db } from '$lib/data/database';
+import type { EntityDescriptor } from '$lib/entities/types';
+
+const calendarEntity: EntityDescriptor = {
+ appId: 'calendar',
+ collection: 'events',
+
+ getDisplayData: (item) => ({
+ title: (item.title as string) || 'Termin',
+ subtitle: item.startDate
+ ? new Date(item.startDate as string).toLocaleDateString('de', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ : undefined,
+ }),
+
+ dragType: 'event',
+ acceptsDropFrom: ['task', 'contact'],
+
+ transformIncoming: {
+ task: (source) => {
+ const dueDate = (source.dueDate as string) || new Date().toISOString();
+ const start = new Date(dueDate);
+ const end = new Date(start.getTime() + 60 * 60 * 1000);
+ return {
+ title: source.title as string,
+ startTime: start.toISOString(),
+ endTime: end.toISOString(),
+ description: source.description as string | undefined,
+ };
+ },
+ contact: (source) => {
+ const name = [source.firstName, source.lastName].filter(Boolean).join(' ');
+ const now = new Date();
+ const end = new Date(now.getTime() + 60 * 60 * 1000);
+ return {
+ title: `Treffen mit ${name}`,
+ startTime: now.toISOString(),
+ endTime: end.toISOString(),
+ };
+ },
+ },
+
+ createItem: async (data) => {
+ const calendars = await db.table('calendars').toArray();
+ const defaultCal = calendars.find((c: Record) => !c.deletedAt);
+ const calendarId = (defaultCal?.id as string) ?? 'default';
+
+ const result = await eventsStore.createEvent({
+ calendarId,
+ title: data.title as string,
+ startTime: data.startTime as string,
+ endTime: data.endTime as string,
+ description: (data.description as string) ?? undefined,
+ });
+
+ if (!result.success || !result.data) throw new Error(result.error || 'Failed to create event');
+ return result.data.id;
+ },
+};
+
+registerEntity(calendarEntity);
diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/views/DetailView.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/views/DetailView.svelte
index 27a208db2..f39bafc56 100644
--- a/apps/manacore/apps/web/src/lib/modules/calendar/views/DetailView.svelte
+++ b/apps/manacore/apps/web/src/lib/modules/calendar/views/DetailView.svelte
@@ -10,6 +10,7 @@
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalEvent } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
+ import LinkedItems from '$lib/components/links/LinkedItems.svelte';
let { navigate, goBack, params }: ViewProps = $props();
let eventId = $derived(params.eventId as string);
@@ -178,6 +179,9 @@
{/if}
+
+
+
Beschreibung
diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/contacts/ListView.svelte
index fa969bd87..2eae06690 100644
--- a/apps/manacore/apps/web/src/lib/modules/contacts/ListView.svelte
+++ b/apps/manacore/apps/web/src/lib/modules/contacts/ListView.svelte
@@ -10,7 +10,7 @@
import { contactsStore } from './stores/contacts.svelte';
import { Plus, Star } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
- import { dropTarget } from '@manacore/shared-ui/dnd';
+ import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@@ -110,6 +110,16 @@
_siblingIds: filtered().map((c) => c.id),
_siblingKey: 'contactId',
})}
+ use:dragSource={{
+ type: 'contact',
+ data: () => ({
+ id: contact.id,
+ firstName: contact.firstName,
+ lastName: contact.lastName,
+ company: contact.company,
+ email: contact.email,
+ }),
+ }}
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(contact.id, p.data as unknown as TagDragData),
diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/entity.ts b/apps/manacore/apps/web/src/lib/modules/contacts/entity.ts
new file mode 100644
index 000000000..8da67557d
--- /dev/null
+++ b/apps/manacore/apps/web/src/lib/modules/contacts/entity.ts
@@ -0,0 +1,20 @@
+import { registerEntity } from '$lib/entities/registry';
+import type { EntityDescriptor } from '$lib/entities/types';
+
+const contactsEntity: EntityDescriptor = {
+ appId: 'contacts',
+ collection: 'contacts',
+
+ getDisplayData: (item) => {
+ const name = [item.firstName, item.lastName].filter(Boolean).join(' ');
+ return {
+ title: name || (item.email as string) || 'Kontakt',
+ subtitle: (item.company as string) ?? undefined,
+ };
+ },
+
+ dragType: 'contact',
+ // Contacts are drag sources only — dropping onto contacts doesn't create a new contact
+};
+
+registerEntity(contactsEntity);
diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/views/DetailView.svelte b/apps/manacore/apps/web/src/lib/modules/contacts/views/DetailView.svelte
index fcb09da32..45f557853 100644
--- a/apps/manacore/apps/web/src/lib/modules/contacts/views/DetailView.svelte
+++ b/apps/manacore/apps/web/src/lib/modules/contacts/views/DetailView.svelte
@@ -19,6 +19,7 @@
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalContact } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
+ import LinkedItems from '$lib/components/links/LinkedItems.svelte';
let { navigate, goBack, params }: ViewProps = $props();
let contactId = $derived(params.contactId as string);
@@ -289,6 +290,9 @@
{/if}
+
+
+
Notizen
diff --git a/apps/manacore/apps/web/src/lib/modules/todo/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/todo/ListView.svelte
index 78d906aad..41d2222d9 100644
--- a/apps/manacore/apps/web/src/lib/modules/todo/ListView.svelte
+++ b/apps/manacore/apps/web/src/lib/modules/todo/ListView.svelte
@@ -15,7 +15,7 @@
import { tasksStore } from './stores/tasks.svelte';
import { Circle, Check } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
- import { dropTarget } from '@manacore/shared-ui/dnd';
+ import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@@ -121,6 +121,15 @@
_siblingKey: 'taskId',
})}
class="task-item"
+ use:dragSource={{
+ type: 'task',
+ data: () => ({
+ id: task.id,
+ title: task.title,
+ dueDate: task.dueDate,
+ description: task.description,
+ }),
+ }}
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(task.id, p.data as unknown as TagDragData),
diff --git a/apps/manacore/apps/web/src/lib/modules/todo/entity.ts b/apps/manacore/apps/web/src/lib/modules/todo/entity.ts
new file mode 100644
index 000000000..1d8c666da
--- /dev/null
+++ b/apps/manacore/apps/web/src/lib/modules/todo/entity.ts
@@ -0,0 +1,36 @@
+import { registerEntity } from '$lib/entities/registry';
+import { tasksStore } from './stores/tasks.svelte';
+import type { EntityDescriptor } from '$lib/entities/types';
+
+const todoEntity: EntityDescriptor = {
+ appId: 'todo',
+ collection: 'tasks',
+
+ getDisplayData: (item) => ({
+ title: (item.title as string) || 'Aufgabe',
+ subtitle: item.dueDate ? new Date(item.dueDate as string).toLocaleDateString('de') : undefined,
+ }),
+
+ dragType: 'task',
+ acceptsDropFrom: ['event', 'contact'],
+
+ transformIncoming: {
+ event: (source) => ({
+ title: source.title as string,
+ dueDate: source.startDate as string,
+ description: source.description as string | undefined,
+ }),
+ contact: (source) => ({
+ title: `Kontaktieren: ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`,
+ }),
+ },
+
+ createItem: async (data) => {
+ const task = await tasksStore.createTask(
+ data as { title: string; dueDate?: string; description?: string }
+ );
+ return task.id;
+ },
+};
+
+registerEntity(todoEntity);
diff --git a/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte b/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte
index 56c2685fe..1146435b6 100644
--- a/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte
+++ b/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte
@@ -10,6 +10,7 @@
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalTask, TaskPriority } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
+ import LinkedItems from '$lib/components/links/LinkedItems.svelte';
let { navigate, goBack, params }: ViewProps = $props();
let taskId = $derived(params.taskId as string);
@@ -190,6 +191,9 @@
{/if}
+
+
+
Beschreibung