feat(shared-links): deep-link navigation to target app detail views

ManaLinkBadge now resolves the correct URL for the linked record's
detail view (e.g. /event/{id}, /contacts/{id}, /deck/{id}) instead
of just linking to the app's root page.

Uses an anchor tag by default for standard browser navigation, with
onclick prop override for custom behavior. Supports all 12 apps with
their specific routing patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 12:14:29 +02:00
parent fbe3c955e3
commit 4cee74e15d
3 changed files with 114 additions and 11 deletions

View file

@ -0,0 +1,80 @@
/**
* ManaLink Deep-Link Resolution
*
* Maps app + collection + recordId to a full URL that opens
* the record's detail view in the target app.
*/
import { APP_URLS } from '@manacore/shared-branding';
import type { AppIconId } from '@manacore/shared-branding';
/** Route pattern per app and collection. Use {id} as placeholder. */
const DEEP_LINK_PATTERNS: Record<string, Record<string, string>> = {
todo: {
// Todo uses inline editing, no detail route — link to app root
tasks: '/',
projects: '/',
},
calendar: {
events: '/event/{id}',
calendars: '/',
},
contacts: {
contacts: '/contacts/{id}',
},
chat: {
conversations: '/chat/{id}',
messages: '/chat/{id}', // Navigate to conversation, not individual message
},
picture: {
images: '/app/board/{id}',
boards: '/app/board/{id}',
},
storage: {
files: '/',
folders: '/files/{id}',
},
presi: {
decks: '/deck/{id}',
slides: '/deck/{id}', // Navigate to the deck containing the slide
},
context: {
documents: '/documents/{id}',
spaces: '/spaces/{id}',
},
manadeck: {
decks: '/decks/{id}',
cards: '/decks/{id}', // Navigate to deck containing the card
},
mukke: {
songs: '/',
playlists: '/playlists/{id}',
},
clock: {
alarms: '/',
timers: '/',
},
zitare: {
favorites: '/',
},
};
/**
* Resolve a deep link URL for a cross-app record.
*
* @param app - App ID (e.g. 'todo', 'calendar')
* @param collection - Collection name (e.g. 'tasks', 'events')
* @param recordId - Record UUID
* @returns Full URL to the record's detail view, or app root as fallback
*/
export function resolveDeepLink(app: string, collection: string, recordId: string): string {
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const urls = APP_URLS[app as AppIconId];
if (!urls) return '#';
const baseUrl = isDev ? urls.dev : urls.prod;
const pattern = DEEP_LINK_PATTERNS[app]?.[collection] ?? '/';
const path = pattern.replace('{id}', recordId);
return `${baseUrl}${path}`;
}

View file

@ -24,3 +24,6 @@ export {
// Resolvers
export { buildCachedData, isCacheStale } from './resolvers.js';
// Deep Links
export { resolveDeepLink } from './deep-links.js';

View file

@ -2,6 +2,7 @@
import type { LocalManaLink } from '../types.js';
import { getManaApp } from '@manacore/shared-branding';
import type { AppIconId } from '@manacore/shared-branding';
import { resolveDeepLink } from '../deep-links.js';
interface Props {
link: LocalManaLink;
@ -17,20 +18,37 @@
);
let displayColor = $derived(link.cachedTarget?.color ?? targetApp?.color ?? '#6b7280');
let displayAppName = $derived(link.cachedTarget?.appName ?? targetApp?.name ?? link.targetApp);
let deepLinkUrl = $derived(resolveDeepLink(link.targetApp, link.targetCollection, link.targetId));
</script>
<span class="manalink-badge" role="group">
<button
class="manalink-main"
onclick={() => onclick?.(link)}
title="{displayAppName}: {displayTitle}"
>
<span class="manalink-dot" style:background-color={displayColor}></span>
<span class="manalink-title">{displayTitle}</span>
{#if link.cachedTarget?.subtitle}
<span class="manalink-subtitle">{link.cachedTarget.subtitle}</span>
{/if}
</button>
{#if onclick}
<button
class="manalink-main"
onclick={() => onclick(link)}
title="{displayAppName}: {displayTitle}"
>
<span class="manalink-dot" style:background-color={displayColor}></span>
<span class="manalink-title">{displayTitle}</span>
{#if link.cachedTarget?.subtitle}
<span class="manalink-subtitle">{link.cachedTarget.subtitle}</span>
{/if}
</button>
{:else}
<a
class="manalink-main"
href={deepLinkUrl}
target="_blank"
rel="noopener"
title="{displayAppName}: {displayTitle}"
>
<span class="manalink-dot" style:background-color={displayColor}></span>
<span class="manalink-title">{displayTitle}</span>
{#if link.cachedTarget?.subtitle}
<span class="manalink-subtitle">{link.cachedTarget.subtitle}</span>
{/if}
</a>
{/if}
{#if onRemove}
<button class="manalink-remove" onclick={() => onRemove?.(link.pairId)} title="Link entfernen">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
@ -70,6 +88,8 @@
padding: 0.25rem 0.625rem;
border: none;
background: transparent;
text-decoration: none;
color: inherit;
cursor: pointer;
font-size: inherit;
line-height: inherit;