feat(manacore/web): add entity descriptor system with cross-module drag-and-drop

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 12:42:38 +02:00
parent d73591865c
commit 7ee57b7afd
14 changed files with 450 additions and 4 deletions

View file

@ -0,0 +1,116 @@
<!--
LinkedItems — Shows cross-module links for a record.
Displays linked items as clickable pills with app color and cached title.
-->
<script lang="ts">
import {
useLinksForRecord,
type ManaRecordRef,
type LocalManaLink,
} from '@manacore/shared-links';
import { getAppEntry } from '$lib/components/workbench/app-registry';
import { ArrowSquareOut } from '@manacore/shared-icons';
interface Props {
recordRef: ManaRecordRef;
}
let { recordRef }: Props = $props();
const linksQuery = useLinksForRecord(recordRef);
let links = $derived(linksQuery.value ?? []);
</script>
{#if links.length > 0}
<div class="section">
<span class="section-label">Verknüpfungen</span>
<div class="links-list">
{#each links as link (link.id)}
{@const appEntry = getAppEntry(link.targetApp)}
{@const color = link.cachedTarget?.color ?? appEntry?.color ?? '#6B7280'}
<div class="link-item">
<span class="link-dot" style="background: {color}"></span>
<div class="link-content">
<span class="link-title">{link.cachedTarget?.title ?? link.targetId}</span>
{#if link.cachedTarget?.subtitle}
<span class="link-subtitle">{link.cachedTarget.subtitle}</span>
{/if}
</div>
<span class="link-app">{link.cachedTarget?.appName ?? link.targetApp}</span>
</div>
{/each}
</div>
</div>
{/if}
<style>
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.links-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.link-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.04);
transition: background 0.15s;
}
.link-item:hover {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .link-item {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.04);
}
:global(.dark) .link-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.link-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
}
.link-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.link-title {
font-size: 0.8125rem;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.dark) .link-title {
color: #e5e7eb;
}
.link-subtitle {
font-size: 0.6875rem;
color: #9ca3af;
}
.link-app {
font-size: 0.625rem;
color: #b0afa8;
flex-shrink: 0;
}
</style>

View file

@ -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<string, unknown>, sourceEntity.appId, appId);
}
// ── List View (always loaded) ───────────────────────────
let ListComponent = $state<Component | null>(null);
let loadError = $state(false);
@ -163,7 +176,14 @@
});
</script>
<div class="app-page-wrapper">
<div
class="app-page-wrapper"
use:dropTarget={{
accepts: acceptedDropTypes,
onDrop: handleCrossModuleDrop,
canDrop: (p) => canDrop(p.type, appId),
}}
>
<!-- Base: PageShell with list view (always visible) -->
<PageShell
{widthPx}
@ -239,6 +259,15 @@
.app-page-wrapper {
position: relative;
}
:global(.app-page-wrapper.mana-drop-target-hover) :global(.page-shell) {
outline: 2px solid rgba(139, 92, 246, 0.5);
outline-offset: -2px;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.15);
}
:global(.app-page-wrapper.mana-drop-target-success) :global(.page-shell) {
outline: 2px solid rgba(34, 197, 94, 0.5);
outline-offset: -2px;
}
.load-state {
display: flex;

View file

@ -0,0 +1,17 @@
// Types
export type { EntityDescriptor, EntityDisplayData, DropResult } from './types';
// Registry
export {
registerEntity,
getEntity,
getEntityByDragType,
canDrop,
executeDrop,
getAllEntities,
} from './registry';
// Register module entities (side-effect imports)
import '$lib/modules/todo/entity';
import '$lib/modules/calendar/entity';
import '$lib/modules/contacts/entity';

View file

@ -0,0 +1,79 @@
/**
* Entity Registry Collects module descriptors and orchestrates cross-module drops.
*/
import type { DragType } from '@manacore/shared-ui/dnd';
import { linkMutations, buildCachedData } from '@manacore/shared-links';
import type { EntityDescriptor, DropResult } from './types';
const entities = new Map<string, EntityDescriptor>();
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<string, unknown>,
sourceAppId: string,
targetAppId: string
): Promise<DropResult> {
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());
}

View file

@ -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<string, unknown>) => 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<DragType, (sourceItem: Record<string, unknown>) => Record<string, unknown>>
>;
/** Create a new item in this module. Returns the new item's ID. */
createItem?: (data: Record<string, unknown>) => Promise<string>;
}
export interface DropResult {
newItemId: string;
linkPairId: string;
}

View file

@ -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),

View file

@ -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<string, unknown>) => !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);

View file

@ -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 @@
</div>
{/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'calendar', collection: 'events', id: eventId }} />
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>

View file

@ -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),

View file

@ -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);

View file

@ -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 @@
</div>
{/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'contacts', collection: 'contacts', id: contactId }} />
<!-- Notes -->
<div class="section">
<span class="section-label">Notizen</span>

View file

@ -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),

View file

@ -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);

View file

@ -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 @@
</div>
{/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'todo', collection: 'tasks', id: taskId }} />
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>