mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 02:01:25 +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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue