mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
feat(shared-links): add ManaLinkPicker for cross-app search and linking
New modal component that searches across app databases (calendar events, contacts) and creates bidirectional links with cached display data. Integrated into Todo's TaskEditModal with "+ Verknüpfen" button. Search uses debounced text matching against IndexedDB records with lazy-initialized cross-app readers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1bbf0ef41e
commit
fbe3c955e3
4 changed files with 479 additions and 2 deletions
|
|
@ -25,7 +25,8 @@
|
|||
TagSelector,
|
||||
} from './form';
|
||||
import { ContactSelector, focusTrap } from '@manacore/shared-ui';
|
||||
import { ManaLinkList } from '@manacore/shared-links/ui';
|
||||
import { ManaLinkList, ManaLinkPicker } from '@manacore/shared-links/ui';
|
||||
import { searchCrossApp } from '$lib/data/cross-app-search';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
|
|
@ -53,6 +54,8 @@
|
|||
let storyPoints = $state<number | null>(null);
|
||||
let effectiveDuration = $state<EffectiveDuration | null>(null);
|
||||
let funRating = $state<number | null>(null);
|
||||
// Link picker state
|
||||
let showLinkPicker = $state(false);
|
||||
// Contact associations
|
||||
let assignee = $state<ContactOrManual[]>([]);
|
||||
let involvedContacts = $state<ContactOrManual[]>([]);
|
||||
|
|
@ -318,10 +321,27 @@
|
|||
|
||||
<!-- Verknüpfungen -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Verknüpfungen</label>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="form-label">Verknüpfungen</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline"
|
||||
onclick={() => (showLinkPicker = true)}
|
||||
>
|
||||
+ Verknüpfen
|
||||
</button>
|
||||
</div>
|
||||
<ManaLinkList recordRef={{ app: 'todo', collection: 'tasks', id: task.id }} editable />
|
||||
</div>
|
||||
|
||||
<ManaLinkPicker
|
||||
sourceRef={{ app: 'todo', collection: 'tasks', id: task.id }}
|
||||
sourceTitle={title || task.title}
|
||||
open={showLinkPicker}
|
||||
onClose={() => (showLinkPicker = false)}
|
||||
onSearch={searchCrossApp}
|
||||
/>
|
||||
|
||||
<!-- Wiederholung -->
|
||||
<div class="form-section">
|
||||
<label class="form-label" for="task-recurrence">Wiederholung</label>
|
||||
|
|
|
|||
134
apps/todo/apps/web/src/lib/data/cross-app-search.ts
Normal file
134
apps/todo/apps/web/src/lib/data/cross-app-search.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Cross-App Search for ManaLinkPicker
|
||||
*
|
||||
* Searches across multiple apps' IndexedDB databases to find
|
||||
* records that can be linked to. Used by the ManaLinkPicker component.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
|
||||
// ─── Minimal types for search ───────────────────────────────
|
||||
|
||||
interface SearchableRecord extends BaseRecord {
|
||||
title?: string;
|
||||
name?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
allDay?: boolean;
|
||||
color?: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
app: string;
|
||||
collection: string;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// ─── Readers (opened lazily on first search) ────────────────
|
||||
|
||||
let calendarReader: ReturnType<typeof createLocalStore> | null = null;
|
||||
let contactsReader: ReturnType<typeof createLocalStore> | null = null;
|
||||
|
||||
async function getCalendarReader() {
|
||||
if (!calendarReader) {
|
||||
calendarReader = createLocalStore({
|
||||
appId: 'calendar',
|
||||
collections: [{ name: 'events', indexes: ['startDate'] }],
|
||||
});
|
||||
await calendarReader.initialize();
|
||||
}
|
||||
return calendarReader;
|
||||
}
|
||||
|
||||
async function getContactsReader() {
|
||||
if (!contactsReader) {
|
||||
contactsReader = createLocalStore({
|
||||
appId: 'contacts',
|
||||
collections: [{ name: 'contacts', indexes: ['firstName', 'lastName', 'email'] }],
|
||||
});
|
||||
await contactsReader.initialize();
|
||||
}
|
||||
return contactsReader;
|
||||
}
|
||||
|
||||
// ─── Search ─────────────────────────────────────────────────
|
||||
|
||||
function matchesQuery(record: SearchableRecord, q: string): boolean {
|
||||
const lower = q.toLowerCase();
|
||||
const fields = [
|
||||
record.title,
|
||||
record.name,
|
||||
record.firstName,
|
||||
record.lastName,
|
||||
record.email,
|
||||
record.description,
|
||||
];
|
||||
return fields.some((f) => f?.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across calendar events and contacts.
|
||||
* Todo tasks are excluded since we're linking FROM a todo task.
|
||||
*/
|
||||
export async function searchCrossApp(query: string): Promise<SearchResult[]> {
|
||||
const results: SearchResult[] = [];
|
||||
const q = query.toLowerCase();
|
||||
|
||||
// Search calendar events
|
||||
try {
|
||||
const cal = await getCalendarReader();
|
||||
const events = await cal.collection<SearchableRecord>('events').getAll();
|
||||
for (const event of events) {
|
||||
if (event.deletedAt) continue;
|
||||
if (matchesQuery(event, q)) {
|
||||
const startDate = event.startDate
|
||||
? new Date(event.startDate).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
: undefined;
|
||||
results.push({
|
||||
app: 'calendar',
|
||||
collection: 'events',
|
||||
id: event.id,
|
||||
title: event.title ?? 'Termin',
|
||||
subtitle: startDate,
|
||||
color: (event.color as string) ?? '#3B82F6',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Calendar DB may not exist yet
|
||||
}
|
||||
|
||||
// Search contacts
|
||||
try {
|
||||
const con = await getContactsReader();
|
||||
const contacts = await con.collection<SearchableRecord>('contacts').getAll();
|
||||
for (const contact of contacts) {
|
||||
if (contact.deletedAt) continue;
|
||||
if (matchesQuery(contact, q)) {
|
||||
const name = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
results.push({
|
||||
app: 'contacts',
|
||||
collection: 'contacts',
|
||||
id: contact.id,
|
||||
title: name || contact.email || 'Kontakt',
|
||||
subtitle: contact.email,
|
||||
color: '#EC4899',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Contacts DB may not exist yet
|
||||
}
|
||||
|
||||
return results.slice(0, 20);
|
||||
}
|
||||
322
packages/shared-links/src/ui/ManaLinkPicker.svelte
Normal file
322
packages/shared-links/src/ui/ManaLinkPicker.svelte
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ManaLinkPicker - Modal to search and create cross-app links
|
||||
*
|
||||
* Searches across all app databases (todo tasks, calendar events,
|
||||
* contacts, storage files, etc.) and creates bidirectional links.
|
||||
*/
|
||||
|
||||
import { MANA_APPS, getManaApp } from '@manacore/shared-branding';
|
||||
import type { AppIconId } from '@manacore/shared-branding';
|
||||
import { linkMutations } from '../mutations.svelte.js';
|
||||
import { buildCachedData } from '../resolvers.js';
|
||||
import type { ManaRecordRef } from '../types.js';
|
||||
|
||||
interface SearchResult {
|
||||
app: string;
|
||||
collection: string;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The record we're creating a link FROM. */
|
||||
sourceRef: ManaRecordRef;
|
||||
/** Cached display data for the source record. */
|
||||
sourceTitle: string;
|
||||
/** Whether the picker is open. */
|
||||
open: boolean;
|
||||
/** Called when the picker should close. */
|
||||
onClose: () => void;
|
||||
/** Optional: called after a link is created. */
|
||||
onLinkCreated?: () => void;
|
||||
/** Search function provided by the consuming app (searches across IndexedDBs). */
|
||||
onSearch: (query: string) => Promise<SearchResult[]>;
|
||||
}
|
||||
|
||||
let { sourceRef, sourceTitle, open, onClose, onLinkCreated, onSearch }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let searching = $state(false);
|
||||
let creating = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
query = value;
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
if (value.trim().length < 2) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
searching = true;
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
results = await onSearch(value.trim());
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
searching = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function handleSelect(result: SearchResult) {
|
||||
creating = true;
|
||||
try {
|
||||
await linkMutations.createLink({
|
||||
sourceApp: sourceRef.app,
|
||||
sourceCollection: sourceRef.collection,
|
||||
sourceId: sourceRef.id,
|
||||
targetApp: result.app,
|
||||
targetCollection: result.collection,
|
||||
targetId: result.id,
|
||||
cachedSource: buildCachedData(sourceRef.app, sourceTitle),
|
||||
cachedTarget: buildCachedData(result.app, result.title, result.subtitle),
|
||||
});
|
||||
onLinkCreated?.();
|
||||
onClose();
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
function getAppColor(appId: string): string {
|
||||
return getManaApp(appId as AppIconId)?.color ?? '#6b7280';
|
||||
}
|
||||
|
||||
function getAppName(appId: string): string {
|
||||
return getManaApp(appId as AppIconId)?.name ?? appId;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_interactive_supports_focus -->
|
||||
<div
|
||||
class="manalinkpicker-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Verknüpfung erstellen"
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="manalinkpicker-backdrop" onclick={onClose}></div>
|
||||
<div class="manalinkpicker-modal">
|
||||
<div class="manalinkpicker-header">
|
||||
<h3>Verknüpfung erstellen</h3>
|
||||
<button class="manalinkpicker-close" onclick={onClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="manalinkpicker-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Tasks, Events, Kontakten, Dateien..."
|
||||
value={query}
|
||||
oninput={handleInput}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="manalinkpicker-results">
|
||||
{#if searching}
|
||||
<div class="manalinkpicker-status">Suche...</div>
|
||||
{:else if query.length >= 2 && results.length === 0}
|
||||
<div class="manalinkpicker-status">Keine Ergebnisse</div>
|
||||
{:else}
|
||||
{#each results as result (result.id)}
|
||||
<button
|
||||
class="manalinkpicker-result"
|
||||
onclick={() => handleSelect(result)}
|
||||
disabled={creating}
|
||||
>
|
||||
<span
|
||||
class="manalinkpicker-dot"
|
||||
style:background-color={result.color ?? getAppColor(result.app)}
|
||||
></span>
|
||||
<div class="manalinkpicker-info">
|
||||
<span class="manalinkpicker-title">{result.title}</span>
|
||||
{#if result.subtitle}
|
||||
<span class="manalinkpicker-subtitle">{result.subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="manalinkpicker-app">{getAppName(result.app)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.manalinkpicker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.manalinkpicker-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.manalinkpicker-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 60vh;
|
||||
background: var(--color-background, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manalinkpicker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.manalinkpicker-header h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manalinkpicker-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.manalinkpicker-close:hover {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.manalinkpicker-search {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.manalinkpicker-search input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface, #f9fafb);
|
||||
color: var(--color-text, #111827);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.manalinkpicker-search input:focus {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-ring, rgba(99, 102, 241, 0.2));
|
||||
}
|
||||
|
||||
.manalinkpicker-results {
|
||||
overflow-y: auto;
|
||||
max-height: 40vh;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.manalinkpicker-status {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.manalinkpicker-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.manalinkpicker-result:hover {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.manalinkpicker-result:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.manalinkpicker-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.manalinkpicker-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.manalinkpicker-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.manalinkpicker-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.manalinkpicker-app {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as ManaLinkBadge } from './ManaLinkBadge.svelte';
|
||||
export { default as ManaLinkList } from './ManaLinkList.svelte';
|
||||
export { default as ManaLinkPicker } from './ManaLinkPicker.svelte';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue