mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat: add @manacore/shared-links for cross-app record linking
New shared package enabling bidirectional links between records across apps (e.g. todo→calendar, task→file). Each link creates a forward+reverse pair sharing a pairId for efficient queries from both sides. Stored in dedicated IndexedDB (manacore-links), synced via mana-sync. Includes: types, store, mutations, reactive queries, cached display data resolvers, ManaLinkBadge and ManaLinkList UI components. Integrates into Todo app as first consumer — link store initialized in layout, ManaLinkList rendered in TaskEditModal. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
928d88aabd
commit
2222ce25e5
14 changed files with 614 additions and 1 deletions
|
|
@ -40,6 +40,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@manacore/local-store": "workspace:*",
|
"@manacore/local-store": "workspace:*",
|
||||||
|
"@manacore/shared-links": "workspace:*",
|
||||||
"@manacore/shared-api-client": "workspace:*",
|
"@manacore/shared-api-client": "workspace:*",
|
||||||
"@manacore/shared-app-onboarding": "workspace:*",
|
"@manacore/shared-app-onboarding": "workspace:*",
|
||||||
"@manacore/shared-auth": "workspace:*",
|
"@manacore/shared-auth": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
TagSelector,
|
TagSelector,
|
||||||
} from './form';
|
} from './form';
|
||||||
import { ContactSelector, focusTrap } from '@manacore/shared-ui';
|
import { ContactSelector, focusTrap } from '@manacore/shared-ui';
|
||||||
|
import { ManaLinkList } from '@manacore/shared-links/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
|
@ -315,6 +316,12 @@
|
||||||
<SubtaskList {subtasks} onChange={handleSubtasksChange} />
|
<SubtaskList {subtasks} onChange={handleSubtasksChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Verknüpfungen -->
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">Verknüpfungen</label>
|
||||||
|
<ManaLinkList recordRef={{ app: 'todo', collection: 'tasks', id: task.id }} editable />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Wiederholung -->
|
<!-- Wiederholung -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label class="form-label" for="task-recurrence">Wiederholung</label>
|
<label class="form-label" for="task-recurrence">Wiederholung</label>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
tagMutations,
|
tagMutations,
|
||||||
useAllTags as useAllSharedTags,
|
useAllTags as useAllSharedTags,
|
||||||
} from '@manacore/shared-stores';
|
} from '@manacore/shared-stores';
|
||||||
|
import { linkLocalStore, linkMutations } from '@manacore/shared-links';
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
import TaskFilters from '$lib/components/TaskFilters.svelte';
|
import TaskFilters from '$lib/components/TaskFilters.svelte';
|
||||||
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
|
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
|
||||||
|
|
@ -317,18 +318,24 @@
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await authStore.signOut();
|
await authStore.signOut();
|
||||||
tagMutations.stopSync();
|
tagMutations.stopSync();
|
||||||
|
linkMutations.stopSync();
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAuthReady() {
|
async function handleAuthReady() {
|
||||||
// Initialize local-first databases (opens IndexedDB, seeds guest data)
|
// Initialize local-first databases (opens IndexedDB, seeds guest data)
|
||||||
await Promise.all([todoStore.initialize(), tagLocalStore.initialize()]);
|
await Promise.all([
|
||||||
|
todoStore.initialize(),
|
||||||
|
tagLocalStore.initialize(),
|
||||||
|
linkLocalStore.initialize(),
|
||||||
|
]);
|
||||||
|
|
||||||
// If authenticated, start syncing to server
|
// If authenticated, start syncing to server
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
const getToken = () => authStore.getValidToken();
|
const getToken = () => authStore.getValidToken();
|
||||||
todoStore.startSync(getToken);
|
todoStore.startSync(getToken);
|
||||||
tagMutations.startSync(getToken);
|
tagMutations.startSync(getToken);
|
||||||
|
linkMutations.startSync(getToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize split-panel from URL/localStorage
|
// Initialize split-panel from URL/localStorage
|
||||||
|
|
|
||||||
23
packages/shared-links/package.json
Normal file
23
packages/shared-links/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "@manacore/shared-links",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./ui": "./src/ui/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@manacore/local-store": "workspace:*",
|
||||||
|
"@manacore/shared-branding": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/shared-links/src/index.ts
Normal file
26
packages/shared-links/src/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Store
|
||||||
|
export { linkLocalStore, linkCollection } from './store.js';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
LocalManaLink,
|
||||||
|
CreateManaLinkInput,
|
||||||
|
LinkCachedData,
|
||||||
|
ManaRecordRef,
|
||||||
|
ManaLinkType,
|
||||||
|
} from './types.js';
|
||||||
|
export { LINK_TYPE_INVERSIONS } from './types.js';
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
export { linkMutations } from './mutations.svelte.js';
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
export {
|
||||||
|
useLinksForRecord,
|
||||||
|
useLinksForApp,
|
||||||
|
useLinksOfType,
|
||||||
|
useLinkCount,
|
||||||
|
} from './queries.svelte.js';
|
||||||
|
|
||||||
|
// Resolvers
|
||||||
|
export { buildCachedData, isCacheStale } from './resolvers.js';
|
||||||
143
packages/shared-links/src/mutations.svelte.ts
Normal file
143
packages/shared-links/src/mutations.svelte.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* ManaLink — Mutation Service
|
||||||
|
*
|
||||||
|
* Handles creating, deleting, and updating cross-app links.
|
||||||
|
* Each link creates TWO records (forward + reverse) sharing a pairId
|
||||||
|
* for efficient bidirectional queries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { linkLocalStore, linkCollection } from './store.js';
|
||||||
|
import type { LocalManaLink, CreateManaLinkInput, LinkCachedData } from './types.js';
|
||||||
|
import { LINK_TYPE_INVERSIONS } from './types.js';
|
||||||
|
|
||||||
|
/** Invert a directional link type. Symmetric types return unchanged. */
|
||||||
|
function invertLinkType(type: string): string {
|
||||||
|
return LINK_TYPE_INVERSIONS[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
export const linkMutations = {
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Store Lifecycle ===
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
await linkLocalStore.initialize();
|
||||||
|
},
|
||||||
|
|
||||||
|
startSync(getToken: () => Promise<string | null>) {
|
||||||
|
linkLocalStore.startSync(getToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopSync() {
|
||||||
|
linkLocalStore.stopSync();
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Links ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a bidirectional link. Inserts TWO records sharing a pairId:
|
||||||
|
* - Forward: source → target
|
||||||
|
* - Reverse: target → source (swapped)
|
||||||
|
*/
|
||||||
|
async createLink(
|
||||||
|
input: CreateManaLinkInput
|
||||||
|
): Promise<{ forward: LocalManaLink; reverse: LocalManaLink }> {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const pairId = crypto.randomUUID();
|
||||||
|
const linkType = input.linkType ?? 'related';
|
||||||
|
|
||||||
|
const forward: LocalManaLink = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
pairId,
|
||||||
|
direction: 'forward',
|
||||||
|
sourceApp: input.sourceApp,
|
||||||
|
sourceCollection: input.sourceCollection,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
targetApp: input.targetApp,
|
||||||
|
targetCollection: input.targetCollection,
|
||||||
|
targetId: input.targetId,
|
||||||
|
linkType,
|
||||||
|
cachedSource: input.cachedSource,
|
||||||
|
cachedTarget: input.cachedTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reverse: LocalManaLink = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
pairId,
|
||||||
|
direction: 'reverse',
|
||||||
|
sourceApp: input.targetApp,
|
||||||
|
sourceCollection: input.targetCollection,
|
||||||
|
sourceId: input.targetId,
|
||||||
|
targetApp: input.sourceApp,
|
||||||
|
targetCollection: input.sourceCollection,
|
||||||
|
targetId: input.sourceId,
|
||||||
|
linkType: invertLinkType(linkType),
|
||||||
|
cachedSource: input.cachedTarget,
|
||||||
|
cachedTarget: input.cachedSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertedForward = await linkCollection.insert(forward);
|
||||||
|
const insertedReverse = await linkCollection.insert(reverse);
|
||||||
|
|
||||||
|
return { forward: insertedForward, reverse: insertedReverse };
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to create link';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a link pair by pairId. Soft-deletes both forward and reverse records.
|
||||||
|
*/
|
||||||
|
async deleteLinkPair(pairId: string): Promise<void> {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const links = await linkCollection.getAll({
|
||||||
|
pairId,
|
||||||
|
} as Partial<LocalManaLink>);
|
||||||
|
for (const link of links) {
|
||||||
|
await linkCollection.delete(link.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to delete link pair';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a link by its record ID (and its pair partner).
|
||||||
|
*/
|
||||||
|
async deleteLink(linkId: string): Promise<void> {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const link = await linkCollection.get(linkId);
|
||||||
|
if (link) {
|
||||||
|
await this.deleteLinkPair(link.pairId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to delete link';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cached display data on a link record.
|
||||||
|
*/
|
||||||
|
async updateCache(
|
||||||
|
linkId: string,
|
||||||
|
cached: Partial<{ cachedSource: LinkCachedData; cachedTarget: LinkCachedData }>
|
||||||
|
): Promise<void> {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await linkCollection.update(linkId, cached as Partial<LocalManaLink>);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to update link cache';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
64
packages/shared-links/src/queries.svelte.ts
Normal file
64
packages/shared-links/src/queries.svelte.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* ManaLink — Reactive Live Queries
|
||||||
|
*
|
||||||
|
* Svelte 5 reactive queries for cross-app links.
|
||||||
|
* Auto-update when IndexedDB changes (local writes, sync, other tabs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||||
|
import { linkCollection } from './store.js';
|
||||||
|
import type { LocalManaLink, ManaRecordRef } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all links where this record is the source.
|
||||||
|
* Because we store reverse records, this returns links in BOTH directions.
|
||||||
|
*/
|
||||||
|
export function useLinksForRecord(ref: ManaRecordRef) {
|
||||||
|
return useLiveQueryWithDefault(
|
||||||
|
() =>
|
||||||
|
linkCollection.getAll({
|
||||||
|
sourceApp: ref.app,
|
||||||
|
sourceId: ref.id,
|
||||||
|
} as Partial<LocalManaLink>),
|
||||||
|
[] as LocalManaLink[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all links for an entire app.
|
||||||
|
*/
|
||||||
|
export function useLinksForApp(appId: string) {
|
||||||
|
return useLiveQueryWithDefault(
|
||||||
|
() =>
|
||||||
|
linkCollection.getAll({
|
||||||
|
sourceApp: appId,
|
||||||
|
} as Partial<LocalManaLink>),
|
||||||
|
[] as LocalManaLink[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get links of a specific type for a record.
|
||||||
|
*/
|
||||||
|
export function useLinksOfType(ref: ManaRecordRef, linkType: string) {
|
||||||
|
return useLiveQueryWithDefault(async () => {
|
||||||
|
const all = await linkCollection.getAll({
|
||||||
|
sourceApp: ref.app,
|
||||||
|
sourceId: ref.id,
|
||||||
|
} as Partial<LocalManaLink>);
|
||||||
|
return all.filter((l) => l.linkType === linkType);
|
||||||
|
}, [] as LocalManaLink[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count links for a record.
|
||||||
|
*/
|
||||||
|
export function useLinkCount(ref: ManaRecordRef) {
|
||||||
|
return useLiveQueryWithDefault(async () => {
|
||||||
|
const all = await linkCollection.getAll({
|
||||||
|
sourceApp: ref.app,
|
||||||
|
sourceId: ref.id,
|
||||||
|
} as Partial<LocalManaLink>);
|
||||||
|
return all.length;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
34
packages/shared-links/src/resolvers.ts
Normal file
34
packages/shared-links/src/resolvers.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* ManaLink — Display Data Resolvers
|
||||||
|
*
|
||||||
|
* Helpers to build cached display data from app metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getManaApp } from '@manacore/shared-branding';
|
||||||
|
import type { AppIconId } from '@manacore/shared-branding';
|
||||||
|
import type { LinkCachedData } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build LinkCachedData from app branding + a title.
|
||||||
|
* Called by the app creating the link to populate offline-friendly display data.
|
||||||
|
*/
|
||||||
|
export function buildCachedData(appId: string, title: string, subtitle?: string): LinkCachedData {
|
||||||
|
const app = getManaApp(appId as AppIconId);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon: app?.icon,
|
||||||
|
color: app?.color,
|
||||||
|
appName: app?.name,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cached data is stale (older than threshold).
|
||||||
|
* Default: 24 hours.
|
||||||
|
*/
|
||||||
|
export function isCacheStale(cached: LinkCachedData, maxAgeMs = 24 * 60 * 60 * 1000): boolean {
|
||||||
|
const age = Date.now() - new Date(cached.fetchedAt).getTime();
|
||||||
|
return age > maxAgeMs;
|
||||||
|
}
|
||||||
40
packages/shared-links/src/store.ts
Normal file
40
packages/shared-links/src/store.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* ManaLink — Local-First Store
|
||||||
|
*
|
||||||
|
* Creates a shared IndexedDB database ('manacore-links') that all apps
|
||||||
|
* can read from. Links are synced to the server via mana-sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLocalStore } from '@manacore/local-store';
|
||||||
|
import type { LocalManaLink } from './types.js';
|
||||||
|
|
||||||
|
const SYNC_SERVER_URL =
|
||||||
|
(typeof window !== 'undefined' &&
|
||||||
|
(window as unknown as { __PUBLIC_SYNC_SERVER_URL__?: string }).__PUBLIC_SYNC_SERVER_URL__) ||
|
||||||
|
(typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_SYNC_SERVER_URL) ||
|
||||||
|
'http://localhost:3050';
|
||||||
|
|
||||||
|
export const linkLocalStore = createLocalStore({
|
||||||
|
appId: 'links',
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
name: 'links',
|
||||||
|
indexes: [
|
||||||
|
'pairId',
|
||||||
|
'direction',
|
||||||
|
'sourceApp',
|
||||||
|
'sourceId',
|
||||||
|
'targetApp',
|
||||||
|
'targetId',
|
||||||
|
'linkType',
|
||||||
|
'[sourceApp+sourceId]',
|
||||||
|
'[sourceApp+sourceCollection+sourceId]',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sync: {
|
||||||
|
serverUrl: SYNC_SERVER_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const linkCollection = linkLocalStore.collection<LocalManaLink>('links');
|
||||||
92
packages/shared-links/src/types.ts
Normal file
92
packages/shared-links/src/types.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
/**
|
||||||
|
* ManaLink — Cross-App Linking Types
|
||||||
|
*
|
||||||
|
* Defines the data structures for bidirectional links between records
|
||||||
|
* across different apps in the Mana ecosystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseRecord } from '@manacore/local-store';
|
||||||
|
|
||||||
|
/** Cached display data for a linked record (offline-friendly). */
|
||||||
|
export interface LinkCachedData {
|
||||||
|
/** Display title of the linked record. */
|
||||||
|
title: string;
|
||||||
|
/** Optional subtitle (e.g. date, status). */
|
||||||
|
subtitle?: string;
|
||||||
|
/** App icon (data URL from shared-branding). */
|
||||||
|
icon?: string;
|
||||||
|
/** App color hex (from shared-branding). */
|
||||||
|
color?: string;
|
||||||
|
/** Human-readable app name. */
|
||||||
|
appName?: string;
|
||||||
|
/** ISO timestamp when this cache was built. */
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single link record stored in IndexedDB (manacore-links). */
|
||||||
|
export interface LocalManaLink extends BaseRecord {
|
||||||
|
/** UUID shared by the forward and reverse link records. */
|
||||||
|
pairId: string;
|
||||||
|
/** Direction marker for this half of the pair. */
|
||||||
|
direction: 'forward' | 'reverse';
|
||||||
|
/** App ID of the source record (e.g. 'todo'). */
|
||||||
|
sourceApp: string;
|
||||||
|
/** Collection name in the source app (e.g. 'tasks'). */
|
||||||
|
sourceCollection: string;
|
||||||
|
/** Record ID in the source app. */
|
||||||
|
sourceId: string;
|
||||||
|
/** App ID of the target record (e.g. 'calendar'). */
|
||||||
|
targetApp: string;
|
||||||
|
/** Collection name in the target app (e.g. 'events'). */
|
||||||
|
targetCollection: string;
|
||||||
|
/** Record ID in the target app. */
|
||||||
|
targetId: string;
|
||||||
|
/** Semantic link type. */
|
||||||
|
linkType: string;
|
||||||
|
/** Cached display data for the source record. */
|
||||||
|
cachedSource?: LinkCachedData;
|
||||||
|
/** Cached display data for the target record. */
|
||||||
|
cachedTarget?: LinkCachedData;
|
||||||
|
/** User ID (for sync scoping). */
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input for creating a link (user-facing). */
|
||||||
|
export interface CreateManaLinkInput {
|
||||||
|
sourceApp: string;
|
||||||
|
sourceCollection: string;
|
||||||
|
sourceId: string;
|
||||||
|
targetApp: string;
|
||||||
|
targetCollection: string;
|
||||||
|
targetId: string;
|
||||||
|
/** Link type. Default: 'related'. */
|
||||||
|
linkType?: string;
|
||||||
|
cachedSource?: LinkCachedData;
|
||||||
|
cachedTarget?: LinkCachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identifies a record across apps. */
|
||||||
|
export interface ManaRecordRef {
|
||||||
|
app: string;
|
||||||
|
collection: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-in link types. */
|
||||||
|
export type ManaLinkType =
|
||||||
|
| 'related'
|
||||||
|
| 'blocks'
|
||||||
|
| 'blocked-by'
|
||||||
|
| 'time-block'
|
||||||
|
| 'attachment'
|
||||||
|
| 'reference'
|
||||||
|
| 'parent'
|
||||||
|
| 'child';
|
||||||
|
|
||||||
|
/** Map of directional link type inversions. */
|
||||||
|
export const LINK_TYPE_INVERSIONS: Record<string, string> = {
|
||||||
|
blocks: 'blocked-by',
|
||||||
|
'blocked-by': 'blocks',
|
||||||
|
parent: 'child',
|
||||||
|
child: 'parent',
|
||||||
|
};
|
||||||
119
packages/shared-links/src/ui/ManaLinkBadge.svelte
Normal file
119
packages/shared-links/src/ui/ManaLinkBadge.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { LocalManaLink } from '../types.js';
|
||||||
|
import { getManaApp } from '@manacore/shared-branding';
|
||||||
|
import type { AppIconId } from '@manacore/shared-branding';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
link: LocalManaLink;
|
||||||
|
onRemove?: (pairId: string) => void;
|
||||||
|
onclick?: (link: LocalManaLink) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { link, onRemove, onclick }: Props = $props();
|
||||||
|
|
||||||
|
let targetApp = $derived(getManaApp(link.targetApp as AppIconId));
|
||||||
|
let displayTitle = $derived(
|
||||||
|
link.cachedTarget?.title ?? `${link.targetApp}/${link.targetId.slice(0, 8)}`
|
||||||
|
);
|
||||||
|
let displayColor = $derived(link.cachedTarget?.color ?? targetApp?.color ?? '#6b7280');
|
||||||
|
let displayAppName = $derived(link.cachedTarget?.appName ?? targetApp?.name ?? link.targetApp);
|
||||||
|
</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 onRemove}
|
||||||
|
<button class="manalink-remove" onclick={() => onRemove?.(link.pairId)} title="Link entfernen">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path
|
||||||
|
d="M3 3l6 6M9 3l-6 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.manalink-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-surface, #f3f4f6);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-badge:hover {
|
||||||
|
background: var(--color-surface-hover, #e5e7eb);
|
||||||
|
border-color: var(--color-border-hover, #d1d5db);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-subtitle {
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manalink-remove:hover {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
packages/shared-links/src/ui/ManaLinkList.svelte
Normal file
36
packages/shared-links/src/ui/ManaLinkList.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { LocalManaLink, ManaRecordRef } from '../types.js';
|
||||||
|
import { useLinksForRecord } from '../queries.svelte.js';
|
||||||
|
import { linkMutations } from '../mutations.svelte.js';
|
||||||
|
import ManaLinkBadge from './ManaLinkBadge.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recordRef: ManaRecordRef;
|
||||||
|
onclick?: (link: LocalManaLink) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { recordRef, onclick, editable = false }: Props = $props();
|
||||||
|
|
||||||
|
const links = useLinksForRecord(recordRef);
|
||||||
|
|
||||||
|
function handleRemove(pairId: string) {
|
||||||
|
linkMutations.deleteLinkPair(pairId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if (links.value ?? []).length > 0}
|
||||||
|
<div class="manalink-list">
|
||||||
|
{#each links.value ?? [] as link (link.id)}
|
||||||
|
<ManaLinkBadge {link} {onclick} onRemove={editable ? handleRemove : undefined} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.manalink-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
packages/shared-links/src/ui/index.ts
Normal file
2
packages/shared-links/src/ui/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as ManaLinkBadge } from './ManaLinkBadge.svelte';
|
||||||
|
export { default as ManaLinkList } from './ManaLinkList.svelte';
|
||||||
19
packages/shared-links/tsconfig.json
Normal file
19
packages/shared-links/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["svelte"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue