mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
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:
parent
5828f60934
commit
18a94b9266
9 changed files with 143 additions and 39 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(' ');
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue