feat(manacore/web): clickable cross-module links with overlay stacking

Clicking a linked item in a DetailView opens the target app's DetailView
as a stacked overlay on top of the current one. Supports:
- Cross-app navigation (e.g. click linked event from todo detail)
- Back button to return to previous overlay
- Correct app color + title in overlay header
- Overlay stack with goBack() popping the top frame
- Added paramKey to EntityDescriptor for correct ID mapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 13:36:40 +02:00
parent 5828f60934
commit 18a94b9266
9 changed files with 143 additions and 39 deletions

View file

@ -1,6 +1,6 @@
<!--
LinkedItems — Shows cross-module links for a record.
Displays linked items as clickable pills with app color and cached title.
Clicking a link opens the target item's DetailView as a cross-detail overlay.
-->
<script lang="ts">
import {
@ -9,16 +9,24 @@
type LocalManaLink,
} from '@manacore/shared-links';
import { getAppEntry } from '$lib/components/workbench/app-registry';
import { ArrowSquareOut } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
interface Props {
recordRef: ManaRecordRef;
navigate: ViewProps['navigate'];
}
let { recordRef }: Props = $props();
let { recordRef, navigate }: Props = $props();
const linksQuery = useLinksForRecord(recordRef);
let links = $derived(linksQuery.value ?? []);
function openLink(link: LocalManaLink) {
navigate('cross-detail', {
_targetApp: link.targetApp,
_targetId: link.targetId,
});
}
</script>
{#if links.length > 0}
@ -28,7 +36,7 @@
{#each links as link (link.id)}
{@const appEntry = getAppEntry(link.targetApp)}
{@const color = link.cachedTarget?.color ?? appEntry?.color ?? '#6B7280'}
<div class="link-item">
<button class="link-item" onclick={() => openLink(link)}>
<span class="link-dot" style="background: {color}"></span>
<div class="link-content">
<span class="link-title">{link.cachedTarget?.title ?? link.targetId}</span>
@ -37,7 +45,7 @@
{/if}
</div>
<span class="link-app">{link.cachedTarget?.appName ?? link.targetApp}</span>
</div>
</button>
{/each}
</div>
</div>
@ -69,17 +77,19 @@
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.04);
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.link-item:hover {
background: rgba(0, 0, 0, 0.04);
background: rgba(0, 0, 0, 0.05);
}
: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);
background: rgba(255, 255, 255, 0.06);
}
.link-dot {
width: 8px;

View file

@ -4,7 +4,7 @@
that floats slightly larger than the panel underneath.
-->
<script lang="ts">
import { X, CaretUp, CaretDown, SpinnerGap } from '@manacore/shared-icons';
import { X, CaretUp, CaretDown, ArrowLeft, SpinnerGap } from '@manacore/shared-icons';
import { PageShell } from '$lib/components/page-carousel';
import { getAppEntry } from './app-registry';
import type { Component } from 'svelte';
@ -76,23 +76,28 @@
}
});
// ── Overlay ─────────────────────────────────────────────
// ── Overlay Stack ───────────────────────────────────────
interface OverlayFrame {
viewName: string;
params: Record<string, unknown>;
component: Component | null;
/** App color for the overlay header (for cross-detail). */
overlayColor?: string;
/** App name for the overlay header (for cross-detail). */
overlayTitle?: string;
}
let overlay = $state<OverlayFrame | null>(null);
let hasOverlay = $derived(overlay !== null);
let overlayStack = $state<OverlayFrame[]>([]);
let overlay = $derived(overlayStack.length > 0 ? overlayStack[overlayStack.length - 1] : null);
let hasOverlay = $derived(overlayStack.length > 0);
// Sibling item IDs for prev/next navigation
// Sibling item IDs for prev/next navigation (only for first overlay level)
let siblingIds = $state<string[]>([]);
let siblingKey = $state<string>('');
let cachedOverlayComponent = $state<Component | null>(null);
let currentSiblingIndex = $derived(() => {
if (!overlay || !siblingKey || siblingIds.length === 0) return -1;
if (!overlay || !siblingKey || siblingIds.length === 0 || overlayStack.length > 1) return -1;
const currentId = overlay.params[siblingKey] as string;
return siblingIds.indexOf(currentId);
});
@ -103,9 +108,42 @@
function navigate(viewName: string, params: Record<string, unknown> = {}) {
if (viewName === 'list') {
overlay = null;
overlayStack = [];
siblingIds = [];
siblingKey = '';
return;
}
// Cross-detail: open a detail view from another app
if (viewName === 'cross-detail') {
const targetApp = params._targetApp as string;
const targetId = params._targetId as string;
if (!targetApp || !targetId) return;
const targetEntity = getEntity(targetApp);
const targetAppEntry = getAppEntry(targetApp);
const targetViewEntry = targetAppEntry?.views?.detail;
if (!targetViewEntry || !targetEntity) {
console.warn(`No detail view registered for app "${targetApp}"`);
return;
}
targetViewEntry.load().then((mod) => {
overlayStack = [
...overlayStack,
{
viewName: 'cross-detail',
params: { [targetEntity.paramKey]: targetId },
component: mod.default,
overlayColor: targetAppEntry?.color,
overlayTitle: targetAppEntry?.name,
},
];
});
return;
}
// Normal detail view within the same app
const viewEntry = appEntry?.views?.[viewName];
if (!viewEntry) {
console.warn(`View "${viewName}" not registered for app "${appId}"`);
@ -117,7 +155,7 @@
if (ids && key) {
siblingIds = ids;
siblingKey = key;
} else if (!overlay) {
} else if (overlayStack.length === 0) {
siblingIds = [];
siblingKey = '';
}
@ -128,24 +166,32 @@
viewEntry.load().then((mod) => {
cachedOverlayComponent = mod.default;
overlay = { viewName, params: viewParams, component: mod.default };
// Replace the stack (not push) for same-app detail navigation
overlayStack = [{ viewName, params: viewParams, component: mod.default }];
});
}
function goBack() {
overlay = null;
siblingIds = [];
siblingKey = '';
if (overlayStack.length > 1) {
// Pop the top overlay (cross-detail)
overlayStack = overlayStack.slice(0, -1);
} else {
overlayStack = [];
siblingIds = [];
siblingKey = '';
}
}
function goToPrev() {
const idx = currentSiblingIndex();
if (idx > 0 && overlay && siblingKey && cachedOverlayComponent) {
overlay = {
viewName: overlay.viewName,
params: { ...overlay.params, [siblingKey]: siblingIds[idx - 1] },
component: cachedOverlayComponent,
};
if (idx > 0 && overlay && siblingKey && cachedOverlayComponent && overlayStack.length === 1) {
overlayStack = [
{
viewName: overlay.viewName,
params: { ...overlay.params, [siblingKey]: siblingIds[idx - 1] },
component: cachedOverlayComponent,
},
];
}
}
@ -156,13 +202,16 @@
idx < siblingIds.length - 1 &&
overlay &&
siblingKey &&
cachedOverlayComponent
cachedOverlayComponent &&
overlayStack.length === 1
) {
overlay = {
viewName: overlay.viewName,
params: { ...overlay.params, [siblingKey]: siblingIds[idx + 1] },
component: cachedOverlayComponent,
};
overlayStack = [
{
viewName: overlay.viewName,
params: { ...overlay.params, [siblingKey]: siblingIds[idx + 1] },
component: cachedOverlayComponent,
},
];
}
}
@ -173,7 +222,7 @@
if (!overlay) return;
function handleGlobalClick(e: MouseEvent) {
if (overlayCardEl && !overlayCardEl.contains(e.target as Node)) {
overlay = null;
overlayStack = [];
siblingIds = [];
siblingKey = '';
}
@ -239,14 +288,28 @@
<!-- Header -->
<div class="overlay-header">
<span class="color-dot" style="background-color: {appColor}"></span>
<span class="overlay-title">{appName}</span>
{#if siblingIds.length > 1}
{#if overlayStack.length > 1}
<button class="back-btn" onclick={goBack} title="Zurück">
<ArrowLeft size={12} />
</button>
{/if}
<span class="color-dot" style="background-color: {overlay.overlayColor ?? appColor}"
></span>
<span class="overlay-title">{overlay.overlayTitle ?? appName}</span>
{#if siblingIds.length > 1 && overlayStack.length === 1}
<span class="nav-counter">
{currentSiblingIndex() + 1}/{siblingIds.length}
</span>
{/if}
<button class="close-btn" onclick={goBack} title="Schließen">
<button
class="close-btn"
onclick={() => {
overlayStack = [];
siblingIds = [];
siblingKey = '';
}}
title="Schließen"
>
<X size={14} />
</button>
</div>
@ -402,6 +465,28 @@
color: #f3f4f6;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.back-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.close-btn {
display: flex;
align-items: center;

View file

@ -16,6 +16,9 @@ export interface EntityDescriptor {
appId: string;
collection: string;
/** Param key name used in DetailView (e.g. 'taskId', 'eventId', 'contactId'). */
paramKey: string;
/** How to display an item in link previews and drag ghosts. */
getDisplayData: (item: Record<string, unknown>) => EntityDisplayData;

View file

@ -6,6 +6,7 @@ import type { EntityDescriptor } from '$lib/entities/types';
const calendarEntity: EntityDescriptor = {
appId: 'calendar',
collection: 'events',
paramKey: 'eventId',
getDisplayData: (item) => ({
title: (item.title as string) || 'Termin',

View file

@ -180,7 +180,7 @@
{/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'calendar', collection: 'events', id: eventId }} />
<LinkedItems recordRef={{ app: 'calendar', collection: 'events', id: eventId }} {navigate} />
<!-- Description -->
<div class="section">

View file

@ -4,6 +4,7 @@ import type { EntityDescriptor } from '$lib/entities/types';
const contactsEntity: EntityDescriptor = {
appId: 'contacts',
collection: 'contacts',
paramKey: 'contactId',
getDisplayData: (item) => {
const name = [item.firstName, item.lastName].filter(Boolean).join(' ');

View file

@ -291,7 +291,10 @@
{/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'contacts', collection: 'contacts', id: contactId }} />
<LinkedItems
recordRef={{ app: 'contacts', collection: 'contacts', id: contactId }}
{navigate}
/>
<!-- Notes -->
<div class="section">

View file

@ -5,6 +5,7 @@ import type { EntityDescriptor } from '$lib/entities/types';
const todoEntity: EntityDescriptor = {
appId: 'todo',
collection: 'tasks',
paramKey: 'taskId',
getDisplayData: (item) => ({
title: (item.title as string) || 'Aufgabe',

View file

@ -192,7 +192,7 @@
{/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'todo', collection: 'tasks', id: taskId }} />
<LinkedItems recordRef={{ app: 'todo', collection: 'tasks', id: taskId }} {navigate} />
<!-- Description -->
<div class="section">