From 7ee57b7afdaafbafef1d017057f6faa003d45c17 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 3 Apr 2026 12:42:38 +0200 Subject: [PATCH] feat(manacore/web): add entity descriptor system with cross-module drag-and-drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce EntityDescriptor pattern: each module declares how its items can be displayed, dragged, dropped, and created from other modules. - Entity types + registry with executeDrop orchestration - Entity descriptors for todo, calendar, contacts - List items are now draggable (dragSource) across pages - Pages accept cross-module drops (event→todo, contact→calendar, etc.) - Drops create new items + bidirectional manaLinks via shared-links - LinkedItems component shows cross-module links in DetailViews - Visual feedback: page glows purple on valid hover, green on success Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/links/LinkedItems.svelte | 116 ++++++++++++++++++ .../lib/components/workbench/AppPage.svelte | 31 ++++- .../apps/web/src/lib/entities/index.ts | 17 +++ .../apps/web/src/lib/entities/registry.ts | 79 ++++++++++++ .../apps/web/src/lib/entities/types.ts | 40 ++++++ .../src/lib/modules/calendar/ListView.svelte | 13 +- .../web/src/lib/modules/calendar/entity.ts | 67 ++++++++++ .../modules/calendar/views/DetailView.svelte | 4 + .../src/lib/modules/contacts/ListView.svelte | 12 +- .../web/src/lib/modules/contacts/entity.ts | 20 +++ .../modules/contacts/views/DetailView.svelte | 4 + .../web/src/lib/modules/todo/ListView.svelte | 11 +- .../apps/web/src/lib/modules/todo/entity.ts | 36 ++++++ .../lib/modules/todo/views/DetailView.svelte | 4 + 14 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte create mode 100644 apps/manacore/apps/web/src/lib/entities/index.ts create mode 100644 apps/manacore/apps/web/src/lib/entities/registry.ts create mode 100644 apps/manacore/apps/web/src/lib/entities/types.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/entity.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/entity.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/entity.ts diff --git a/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte b/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte new file mode 100644 index 000000000..6c594615d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte @@ -0,0 +1,116 @@ + + + +{#if links.length > 0} +
+ + +
+{/if} + + diff --git a/apps/manacore/apps/web/src/lib/components/workbench/AppPage.svelte b/apps/manacore/apps/web/src/lib/components/workbench/AppPage.svelte index 40739a508..1567f8d1e 100644 --- a/apps/manacore/apps/web/src/lib/components/workbench/AppPage.svelte +++ b/apps/manacore/apps/web/src/lib/components/workbench/AppPage.svelte @@ -8,6 +8,9 @@ import { PageShell } from '$lib/components/page-carousel'; import { getAppEntry } from './app-registry'; import type { Component } from 'svelte'; + import { dropTarget } from '@manacore/shared-ui/dnd'; + import { getEntity, getEntityByDragType, canDrop, executeDrop } from '$lib/entities'; + import type { DragPayload } from '@manacore/shared-ui/dnd'; interface Props { appId: string; @@ -35,6 +38,16 @@ let appName = $derived(appEntry?.name ?? appId); let appColor = $derived(appEntry?.color ?? '#6B7280'); + // ── Cross-module drop target ──────────────────────────── + let targetEntity = $derived(getEntity(appId)); + let acceptedDropTypes = $derived(targetEntity?.acceptsDropFrom ?? []); + + function handleCrossModuleDrop(payload: DragPayload) { + const sourceEntity = getEntityByDragType(payload.type); + if (!sourceEntity) return; + executeDrop(payload.data as Record, sourceEntity.appId, appId); + } + // ── List View (always loaded) ─────────────────────────── let ListComponent = $state(null); let loadError = $state(false); @@ -163,7 +176,14 @@ }); -
+
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} + + +
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} + + +
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} + + +