diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/quick-input-adapter.ts b/apps/manacore/apps/web/src/lib/modules/calendar/quick-input-adapter.ts new file mode 100644 index 000000000..44e309317 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/quick-input-adapter.ts @@ -0,0 +1,76 @@ +/** + * Calendar QuickInputBar Adapter + */ + +import type { InputBarAdapter } from '$lib/quick-input/types'; +import type { QuickInputItem } from '@manacore/shared-ui'; +import { db } from '$lib/data/database'; +import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './utils/event-parser'; +import { toCalendar, toCalendarEvent } from './queries'; +import type { LocalCalendar, LocalEvent } from './types'; +import { format, isSameDay } from 'date-fns'; +import { de } from 'date-fns/locale'; + +export function createAdapter(): InputBarAdapter { + return { + placeholder: 'Neuer Termin oder suchen...', + appIcon: 'calendar', + deferSearch: true, + createText: 'Erstellen', + emptyText: 'Keine Termine gefunden', + + async onSearch(query) { + const q = query.toLowerCase(); + const events = await db.table('events').toArray(); + return events + .filter((e) => !e.deletedAt && e.title?.toLowerCase().includes(q)) + .sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) + .slice(0, 10) + .map((e) => ({ + id: e.id, + title: e.title || '', + subtitle: e.startDate + ? format(new Date(e.startDate), 'dd. MMM yyyy, HH:mm', { locale: de }) + : '', + })); + }, + + onSelect(item: QuickInputItem) { + // Could open event detail modal — for now just navigate + window.dispatchEvent(new CustomEvent('calendar:open-event', { detail: { id: item.id } })); + }, + + onParseCreate(query) { + if (!query.trim()) return null; + const parsed = parseEventInput(query); + const preview = formatParsedEventPreview(parsed); + return { + title: `"${parsed.title}" erstellen`, + subtitle: preview || 'Neuer Termin', + }; + }, + + async onCreate(query) { + if (!query.trim()) return; + const parsed = parseEventInput(query); + const calendars = (await db.table('calendars').toArray()) + .filter((c) => !c.deletedAt) + .map(toCalendar); + const tags = await db.table('tags').toArray(); + const defaultCal = calendars.find((c) => c.isDefault) || calendars[0]; + const resolved = resolveEventIds(parsed, calendars, tags, defaultCal?.id); + + const { eventsStore } = await import('./stores/events.svelte'); + await eventsStore.createEvent({ + calendarId: resolved.calendarId || defaultCal?.id || '', + title: resolved.title, + description: null, + startTime: resolved.startTime || new Date().toISOString(), + endTime: resolved.endTime || new Date(Date.now() + 3600000).toISOString(), + isAllDay: resolved.isAllDay || false, + location: resolved.location || null, + recurrenceRule: resolved.recurrenceRule || null, + }); + }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/quick-input-adapter.ts b/apps/manacore/apps/web/src/lib/modules/contacts/quick-input-adapter.ts new file mode 100644 index 000000000..51c45b66a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/quick-input-adapter.ts @@ -0,0 +1,69 @@ +/** + * Contacts QuickInputBar Adapter + */ + +import type { InputBarAdapter } from '$lib/quick-input/types'; +import type { QuickInputItem } from '@manacore/shared-ui'; +import { db } from '$lib/data/database'; +import { parseContactInput, formatParsedContactPreview } from './utils/contact-parser'; +import type { LocalContact } from './types'; +import { contactModalStore } from './stores/modal.svelte'; + +export function createAdapter(): InputBarAdapter { + return { + placeholder: 'Neuer Kontakt oder suchen...', + appIcon: 'contacts', + deferSearch: true, + createText: 'Erstellen', + emptyText: 'Keine Kontakte gefunden', + + async onSearch(query) { + const q = query.toLowerCase(); + const contacts = await db.table('contacts').toArray(); + return contacts + .filter( + (c) => + !c.deletedAt && + !c.isArchived && + (c.firstName?.toLowerCase().includes(q) || + c.lastName?.toLowerCase().includes(q) || + c.email?.toLowerCase().includes(q) || + c.company?.toLowerCase().includes(q)) + ) + .slice(0, 10) + .map((c) => ({ + id: c.id, + title: [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email || '', + subtitle: c.company || c.email || '', + })); + }, + + onSelect(item: QuickInputItem) { + // Navigate to contact or open detail + window.location.hash = `contact=${item.id}`; + }, + + onParseCreate(query) { + if (!query.trim()) return null; + const parsed = parseContactInput(query); + const preview = formatParsedContactPreview(parsed); + return { + title: parsed.displayName ? `"${parsed.displayName}" erstellen` : 'Kontakt erstellen', + subtitle: preview || 'Neuer Kontakt', + }; + }, + + async onCreate(query) { + if (!query.trim()) return; + const parsed = parseContactInput(query); + // Open the contact modal with prefilled data + contactModalStore.open({ + firstName: parsed.firstName, + lastName: parsed.lastName, + email: parsed.email, + phone: parsed.phone, + company: parsed.company, + }); + }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/utils/contact-parser.ts b/apps/manacore/apps/web/src/lib/modules/contacts/utils/contact-parser.ts new file mode 100644 index 000000000..2d2ce1eec --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/contacts/utils/contact-parser.ts @@ -0,0 +1,144 @@ +/** + * Contact Parser — Natural language contact input parsing. + * + * Ported from apps/contacts/apps/web/src/lib/utils/contact-parser.ts + * + * Examples: + * - "Max Mustermann @ACME Corp max@example.com #kunde" + * - "Anna Schmidt bei Google +49 123 456789" + */ + +import { extractTags, extractAtReference } from '@manacore/shared-utils'; + +export interface ParsedContact { + displayName: string; + firstName?: string; + lastName?: string; + company?: string; + email?: string; + phone?: string; + tagNames: string[]; +} + +export interface ParsedContactWithIds { + displayName: string; + firstName?: string; + lastName?: string; + company?: string; + email?: string; + phone?: string; + tagIds: string[]; +} + +const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/; + +const PHONE_PATTERNS: RegExp[] = [ + /\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/, + /\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/, + /\b\d{6,}\b/, +]; + +const COMPANY_PATTERNS: RegExp[] = [ + /\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i, + /\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i, +]; + +function extractEmail(text: string): { email?: string; remaining: string } { + const match = text.match(EMAIL_PATTERN); + if (match) { + return { email: match[1], remaining: text.replace(EMAIL_PATTERN, '').trim() }; + } + return { email: undefined, remaining: text }; +} + +function extractPhone(text: string): { phone?: string; remaining: string } { + for (const pattern of PHONE_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { phone: match[0].trim(), remaining: text.replace(pattern, '').trim() }; + } + } + return { phone: undefined, remaining: text }; +} + +function extractCompanyPattern(text: string): { company?: string; remaining: string } { + for (const pattern of COMPANY_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { company: match[1].trim(), remaining: text.replace(pattern, '').trim() }; + } + } + return { company: undefined, remaining: text }; +} + +function parseNames(displayName: string): { firstName?: string; lastName?: string } { + const parts = displayName.trim().split(/\s+/); + if (parts.length === 0) return {}; + if (parts.length === 1) return { firstName: parts[0] }; + return { firstName: parts[0], lastName: parts.slice(1).join(' ') }; +} + +export function parseContactInput(input: string): ParsedContact { + let text = input.trim(); + + const tagsResult = extractTags(text); + text = tagsResult.remaining; + const tagNames = tagsResult.value || []; + + const atRefResult = extractAtReference(text); + text = atRefResult.remaining; + let company = atRefResult.value; + + if (!company) { + const companyPatternResult = extractCompanyPattern(text); + text = companyPatternResult.remaining; + company = companyPatternResult.company; + } + + const emailResult = extractEmail(text); + text = emailResult.remaining; + + const phoneResult = extractPhone(text); + text = phoneResult.remaining; + + const displayName = text.replace(/\s+/g, ' ').trim(); + const { firstName, lastName } = parseNames(displayName); + + return { + displayName, + firstName, + lastName, + company, + email: emailResult.email, + phone: phoneResult.phone, + tagNames, + }; +} + +export function resolveContactIds( + parsed: ParsedContact, + tags: { id: string; name: string }[] +): ParsedContactWithIds { + const tagIds = parsed.tagNames + .map((name) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.id) + .filter((id): id is string => !!id); + + return { + displayName: parsed.displayName, + firstName: parsed.firstName, + lastName: parsed.lastName, + company: parsed.company, + email: parsed.email, + phone: parsed.phone, + tagIds, + }; +} + +export function formatParsedContactPreview(parsed: ParsedContact): string { + const parts: string[] = []; + if (parsed.company) parts.push(parsed.company); + if (parsed.email) parts.push(parsed.email); + if (parsed.phone) parts.push(parsed.phone); + if (parsed.tagNames.length > 0) parts.push(parsed.tagNames.map((t) => `#${t}`).join(' ')); + return parts.join(' · '); +} diff --git a/apps/manacore/apps/web/src/lib/modules/planta/quick-input-adapter.ts b/apps/manacore/apps/web/src/lib/modules/planta/quick-input-adapter.ts new file mode 100644 index 000000000..594fe1585 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/quick-input-adapter.ts @@ -0,0 +1,59 @@ +/** + * Planta QuickInputBar Adapter + */ + +import type { InputBarAdapter } from '$lib/quick-input/types'; +import type { QuickInputItem } from '@manacore/shared-ui'; +import { db } from '$lib/data/database'; +import { parsePlantInput, formatParsedPlantPreview } from './utils/plant-parser'; +import { plantTable } from './collections'; + +export function createAdapter(): InputBarAdapter { + return { + placeholder: 'Neue Pflanze oder suchen...', + appIcon: 'plant', + deferSearch: true, + createText: 'Hinzufügen', + emptyText: 'Keine Pflanzen gefunden', + + async onSearch(query) { + const q = query.toLowerCase(); + const plants = await db.table('plants').toArray(); + return (plants as Record[]) + .filter( + (p) => + !(p.deletedAt as string) && + ((p.name as string)?.toLowerCase().includes(q) || + (p.species as string)?.toLowerCase().includes(q)) + ) + .slice(0, 10) + .map((p) => ({ + id: p.id as string, + title: (p.name as string) || '', + subtitle: (p.species as string) || (p.location as string) || '', + })); + }, + + onSelect() {}, + + onParseCreate(query) { + if (!query.trim()) return null; + const parsed = parsePlantInput(query); + const preview = formatParsedPlantPreview(parsed); + return { + title: `"${parsed.name}" hinzufügen`, + subtitle: preview || 'Neue Pflanze', + }; + }, + + async onCreate(query) { + if (!query.trim()) return; + const parsed = parsePlantInput(query); + await plantTable.add({ + id: crypto.randomUUID(), + name: parsed.name, + species: parsed.species, + }); + }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/times/quick-input-adapter.ts b/apps/manacore/apps/web/src/lib/modules/times/quick-input-adapter.ts new file mode 100644 index 000000000..84326f7b5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/quick-input-adapter.ts @@ -0,0 +1,57 @@ +/** + * Times QuickInputBar Adapter + * + * Provides search across time entries (stopwatch sessions) + * and quick-create for new timer/alarm entries. + */ + +import type { InputBarAdapter } from '$lib/quick-input/types'; +import type { QuickInputItem } from '@manacore/shared-ui'; +import { db } from '$lib/data/database'; +import { parseEntryInput, formatParsedEntryPreview } from './utils/entry-parser'; + +export function createAdapter(): InputBarAdapter { + return { + placeholder: 'Suchen in Times...', + appIcon: 'clock', + deferSearch: false, + emptyText: 'Keine Einträge gefunden', + + async onSearch(query) { + const q = query.toLowerCase(); + // Search across alarms and world clocks + const alarms = await db.table('alarms').toArray(); + const worldClocks = await db.table('worldClocks').toArray(); + + const results: QuickInputItem[] = []; + + for (const a of alarms as Record[]) { + if (!(a.deletedAt as string) && (a.label as string)?.toLowerCase().includes(q)) { + results.push({ + id: a.id as string, + title: (a.label as string) || 'Alarm', + subtitle: `${(a.hour as number)?.toString().padStart(2, '0')}:${(a.minute as number)?.toString().padStart(2, '0')}`, + }); + } + } + + for (const wc of worldClocks as Record[]) { + if ( + !(wc.deletedAt as string) && + ((wc.label as string)?.toLowerCase().includes(q) || + (wc.timezone as string)?.toLowerCase().includes(q)) + ) { + results.push({ + id: wc.id as string, + title: (wc.label as string) || (wc.timezone as string) || '', + subtitle: 'Weltuhr', + }); + } + } + + return results.slice(0, 10); + }, + + onSelect() {}, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/todo/quick-input-adapter.ts b/apps/manacore/apps/web/src/lib/modules/todo/quick-input-adapter.ts new file mode 100644 index 000000000..7082581c1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/quick-input-adapter.ts @@ -0,0 +1,65 @@ +/** + * Todo QuickInputBar Adapter + */ + +import type { InputBarAdapter } from '$lib/quick-input/types'; +import type { QuickInputItem } from '@manacore/shared-ui'; +import { goto } from '$app/navigation'; +import { db } from '$lib/data/database'; +import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from './utils/task-parser'; +import type { LocalTask } from './types'; + +export function createAdapter(): InputBarAdapter { + return { + placeholder: 'Neue Aufgabe oder suchen...', + appIcon: 'todo', + deferSearch: true, + createText: 'Erstellen', + emptyText: 'Keine Aufgaben gefunden', + + async onSearch(query) { + const q = query.toLowerCase(); + const tasks = await db.table('tasks').toArray(); + return tasks + .filter( + (t) => + !t.deletedAt && + !t.isCompleted && + (t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)) + ) + .slice(0, 10) + .map((t) => ({ + id: t.id, + title: t.title || '', + subtitle: t.dueDate ? new Date(t.dueDate).toLocaleDateString('de-DE') : 'Keine Frist', + })); + }, + + onSelect(item: QuickInputItem) { + goto(`/todo?task=${item.id}`); + }, + + onParseCreate(query) { + if (!query.trim()) return null; + const parsed = parseTaskInput(query); + return { + title: `"${parsed.title}" erstellen`, + subtitle: formatParsedTaskPreview(parsed) || 'Neue Aufgabe', + }; + }, + + async onCreate(query) { + if (!query.trim()) return; + const parsed = parseTaskInput(query); + const allTags = await db.table('tags').toArray(); + const resolved = resolveTaskIds(parsed, allTags); + const { tasksStore } = await import('./stores/tasks.svelte'); + await tasksStore.createTask({ + title: resolved.title, + dueDate: resolved.dueDate, + priority: resolved.priority, + labelIds: resolved.labelIds, + }); + }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/quick-input/fallback-adapter.ts b/apps/manacore/apps/web/src/lib/quick-input/fallback-adapter.ts new file mode 100644 index 000000000..42df675a6 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/quick-input/fallback-adapter.ts @@ -0,0 +1,40 @@ +/** + * Fallback Adapter — Global cross-app search for non-module pages. + * + * Used on /home, /dashboard, /settings, etc. where no specific module + * is active. Delegates to the SearchRegistry to search across all apps. + */ + +import { goto } from '$app/navigation'; +import type { QuickInputItem } from '@manacore/shared-ui'; +import type { SearchRegistry } from '$lib/search/registry'; +import type { InputBarAdapter } from './types'; + +export function createFallbackAdapter(searchRegistry: SearchRegistry): InputBarAdapter { + return { + placeholder: 'Suchen...', + appIcon: 'search', + deferSearch: false, + emptyText: 'Keine Ergebnisse gefunden', + + async onSearch(query: string): Promise { + const groups = await searchRegistry.search(query, { limit: 5 }); + return groups + .flatMap((g) => + g.results.map((r) => ({ + id: `${r.appId}:${r.id}`, + title: r.title, + subtitle: `${g.appName} · ${r.subtitle ?? r.type}`, + icon: g.appIcon, + _href: r.href, + })) + ) + .slice(0, 10) as QuickInputItem[]; + }, + + onSelect(item: QuickInputItem) { + const href = (item as QuickInputItem & { _href?: string })._href; + if (href) goto(href); + }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/quick-input/registry.ts b/apps/manacore/apps/web/src/lib/quick-input/registry.ts new file mode 100644 index 000000000..413b83eb5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/quick-input/registry.ts @@ -0,0 +1,29 @@ +/** + * Adapter Registry — Maps route prefixes to lazy adapter loaders. + * + * Each entry loads the module's adapter only when the user navigates + * to that module, keeping the initial bundle small. + */ + +type AdapterModule = { createAdapter: (...args: unknown[]) => unknown }; + +const registry = new Map Promise>([ + ['/todo', () => import('$lib/modules/todo/quick-input-adapter')], + ['/calendar', () => import('$lib/modules/calendar/quick-input-adapter')], + ['/contacts', () => import('$lib/modules/contacts/quick-input-adapter')], + ['/times', () => import('$lib/modules/times/quick-input-adapter')], + ['/planta', () => import('$lib/modules/planta/quick-input-adapter')], +]); + +/** + * Find the adapter loader for a given pathname. + * Returns null if no module matches (fallback adapter should be used). + */ +export function getAdapterLoader(pathname: string): (() => Promise) | null { + for (const [prefix, loader] of registry) { + if (pathname === prefix || pathname.startsWith(prefix + '/')) { + return loader; + } + } + return null; +} diff --git a/apps/manacore/apps/web/src/lib/quick-input/types.ts b/apps/manacore/apps/web/src/lib/quick-input/types.ts new file mode 100644 index 000000000..55d517d1e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/quick-input/types.ts @@ -0,0 +1,37 @@ +/** + * Unified QuickInputBar Adapter — Type Definitions + * + * Each module implements InputBarAdapter to provide context-aware + * search, create-preview, and create behavior for the QuickInputBar. + */ + +import type { QuickInputItem, CreatePreview, HighlightPattern } from '@manacore/shared-ui'; + +export interface InputBarAdapter { + // Required + onSearch: (query: string) => Promise; + onSelect: (item: QuickInputItem) => void; + + // Create (optional — modules without create don't set these) + onParseCreate?: (query: string) => CreatePreview | null; + onCreate?: (query: string) => Promise; + onSearchChange?: (query: string, results: QuickInputItem[]) => void; + + // Display + placeholder: string; + appIcon: string; + emptyText?: string; + createText?: string; + deferSearch?: boolean; + + // Calendar-style default selector + defaultOptions?: { id: string; label: string }[]; + selectedDefaultId?: string; + defaultOptionLabel?: string; + onDefaultChange?: (id: string) => void; + + // Highlight patterns + highlightPatterns?: HighlightPattern[]; +} + +export type InputBarAdapterFactory = () => InputBarAdapter;