import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Search, FileText, CheckSquare, ShoppingCart, X, Loader2, CornerDownLeft, Maximize2, Minimize2, Check, } from 'lucide-react'; import { ListItem, SearchFilter, SearchHit, SearchResults, SharedList, TaskList, Thought } from '../lib/types'; import { api } from '../lib/api'; import { formatDue } from '../lib/due'; import { SnippetRenderer } from './SnippetRenderer'; interface CommandPaletteProps { isOpen: boolean; onClose: () => void; onOpenDocument: (docId: string, blockId?: string) => void; onOpenTask: (taskId: string) => void; onOpenList: (listId: string) => void; // For list names and the list filter of the large view lists?: SharedList[]; } type Kind = 'all' | 'document' | 'thought' | 'list_item'; // The filter chips, in this order (Tab and Strg+1–4 follow it) const KINDS: { kind: Kind; label: string }[] = [ { kind: 'all', label: 'Alle' }, { kind: 'document', label: 'Notizen' }, { kind: 'thought', label: 'Aufgaben' }, { kind: 'list_item', label: 'Listen' }, ]; const PRIORITY_LABELS: Record = { urgent: 'Dringend', high: 'Wichtig', normal: 'Normal', low: 'Niedrig' }; // Hits per kind: a few for the overview, more once narrowed to one kind or in the large view const LIMIT_ALL = 40; const LIMIT_ONE = 100; const LIMIT_LARGE = 200; export const CommandPalette: React.FC = ({ isOpen, onClose, onOpenDocument, onOpenTask, onOpenList, lists = [], }) => { const [query, setQuery] = useState(''); const [hits, setHits] = useState([]); const [thoughts, setThoughts] = useState([]); const [listItems, setListItems] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [loading, setLoading] = useState(false); const [kind, setKind] = useState('all'); // Large view (desktop): bigger window, filters per kind and context for every hit const [expanded, setExpanded] = useState(false); const [filter, setFilter] = useState({}); // Hits per kind of the current text, for the chips (also while narrowed to one kind) const [counts, setCounts] = useState<{ query: string; limit: number; values: SearchResults['counts'] }>({ query: '', limit: LIMIT_ALL, values: {} }); const [taskLists, setTaskLists] = useState([]); const [folders, setFolders] = useState([]); const inputRef = useRef(null); const debounceTimerRef = useRef(null); // Group hits by category: Notizen, Aufgaben, Listen const groupedHits = useMemo(() => { const docs: SearchHit[] = []; const tasks: SearchHit[] = []; const listHits: SearchHit[] = []; for (const h of hits) { if (h.type === 'document') { docs.push(h); } else if (h.type === 'thought') { tasks.push(h); } else if (h.type === 'list_item') { listHits.push(h); } } return [ { key: 'documents', label: 'Notizen', icon: FileText, items: docs }, { key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks }, { key: 'lists', label: 'Listen', icon: ShoppingCart, items: listHits }, ].filter((g) => g.items.length > 0); }, [hits]); // Flattened array for simple up/down index navigation const flatHits = useMemo(() => { return groupedHits.flatMap((g) => g.items); }, [groupedHits]); const thoughtById = useMemo(() => new Map(thoughts.map((t) => [t.id, t])), [thoughts]); const itemById = useMemo(() => new Map(listItems.map((li) => [li.id, li])), [listItems]); const listTitle = (id?: string) => lists.find((l) => l.id === id)?.title; const taskListTitle = (id?: string) => (id ? taskLists.find((l) => l.id === id)?.title : 'Persönlich'); // Focus input and reset on open useEffect(() => { if (isOpen) { setQuery(''); setHits([]); setSelectedIndex(0); setKind('all'); setFilter({}); setTimeout(() => inputRef.current?.focus(), 50); } }, [isOpen]); // Task lists and folders for the filters of the large view useEffect(() => { if (!isOpen || !expanded) return; api.getTaskLists().then(setTaskLists).catch(() => {}); api.getFolders().then(setFolders).catch(() => {}); }, [isOpen, expanded]); // Filters only count for the kind they belong to, and only in the large view const activeFilter = useMemo((): SearchFilter => { const limit = expanded ? LIMIT_LARGE : kind === 'all' ? LIMIT_ALL : LIMIT_ONE; const out: SearchFilter = { limit }; if (kind !== 'all') out.type = kind; if (!expanded) return out; if (kind === 'thought') { out.taskStatus = filter.taskStatus; out.taskListId = filter.taskListId; out.priority = filter.priority; out.due = filter.due; } else if (kind === 'document') { out.folder = filter.folder; } else if (kind === 'list_item') { out.listId = filter.listId; out.itemStatus = filter.itemStatus; } return out; }, [kind, expanded, filter]); // 150ms debounce search useEffect(() => { if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); } const trimmed = query.trim(); if (!trimmed) { setHits([]); setLoading(false); setSelectedIndex(0); return; } setLoading(true); debounceTimerRef.current = window.setTimeout(async () => { try { const countLimit = expanded ? LIMIT_LARGE : LIMIT_ALL; // Narrowed to one kind: the chips still need the numbers of all kinds for this text const needCounts = kind !== 'all' && (counts.query !== trimmed || counts.limit !== countLimit); const [res, all] = await Promise.all([ api.searchAll(trimmed, activeFilter), needCounts ? api.searchAll(trimmed, { limit: countLimit }) : Promise.resolve(null), ]); setHits(res.hits || []); setThoughts(res.thoughts || []); setListItems(res.listItems || []); if (kind === 'all') setCounts({ query: trimmed, limit: countLimit, values: res.counts || {} }); else if (all) setCounts({ query: trimmed, limit: countLimit, values: all.counts || {} }); setSelectedIndex(0); } catch { setHits([]); } finally { setLoading(false); } }, 150); return () => { if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); } }; // counts is only read to skip a second request // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, activeFilter]); // Keyboard navigation: ↑, ↓, Enter, Escape; Tab and Strg+1–4 switch the kind useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); return; } if (e.key === 'Tab' && document.activeElement === inputRef.current) { e.preventDefault(); setKind((cur) => { const i = KINDS.findIndex((k) => k.kind === cur); return KINDS[(i + (e.shiftKey ? KINDS.length - 1 : 1)) % KINDS.length].kind; }); return; } if ((e.ctrlKey || e.metaKey) && ['1', '2', '3', '4'].includes(e.key)) { e.preventDefault(); setKind(KINDS[Number(e.key) - 1].kind); return; } if (flatHits.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % flatHits.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length); } else if (e.key === 'Enter') { e.preventDefault(); const selected = flatHits[selectedIndex]; if (selected) { executeSelection(selected); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, flatHits, selectedIndex]); // Tick a task or list item off (or open it again) without leaving the search const toggleDone = async (hit: SearchHit) => { if (hit.type === 'thought') { const t = thoughtById.get(hit.id); if (!t) return; const next = !t.isCompleted; setThoughts((prev) => prev.map((x) => (x.id === t.id ? { ...x, isCompleted: next } : x))); try { await api.updateThought(t.id, { isCompleted: next }); } catch { setThoughts((prev) => prev.map((x) => (x.id === t.id ? { ...x, isCompleted: !next } : x))); } } else if (hit.type === 'list_item') { const li = itemById.get(hit.id); if (!li) return; const next = !li.isCompleted; setListItems((prev) => prev.map((x) => (x.id === li.id ? { ...x, isCompleted: next } : x))); try { await api.toggleListItem(li.listId, li.id, next); } catch { setListItems((prev) => prev.map((x) => (x.id === li.id ? { ...x, isCompleted: !next } : x))); } } }; const executeSelection = (hit: SearchHit) => { onClose(); if (hit.type === 'document') { onOpenDocument(hit.docId || hit.id, hit.blockId); } else if (hit.type === 'thought') { onOpenTask(hit.id); } else if (hit.type === 'list_item') { if (hit.listId) { onOpenList(hit.listId); } } }; if (!isOpen) return null; const countLabel = (k: Kind) => { if (!query.trim() || counts.query !== query.trim()) return null; const v = counts.values || {}; const n = k === 'all' ? (v.document || 0) + (v.thought || 0) + (v.list_item || 0) : v[k] || 0; const capped = k !== 'all' && n >= counts.limit; return capped ? `${n}+` : String(n); }; const setFilterValue = (key: keyof SearchFilter, value: string) => setFilter((prev) => ({ ...prev, [key]: value || undefined })); const selectClass = 'h-8 px-2 input-field text-xs bg-panel max-w-[12rem]'; return (
{/* Search Modal Container */}
e.stopPropagation()} > {/* Top Input Bar */}
setQuery(e.target.value)} placeholder="Globale Suche in Notizen, Aufgaben und Listen…" className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0" /> {/* One fixed slot for the spinner or the clear button, so the field keeps its width while typing */} {loading ? ( ) : query ? ( ) : null} {/* Large view with filters (desktop; on phones the search fills the screen anyway) */}
{/* Kinds as chips, with the number of hits */}
{KINDS.map((k) => { const active = kind === k.kind; const n = countLabel(k.kind); return ( ); })}
{/* Large view: filters of the chosen kind */} {expanded && kind !== 'all' && (
{kind === 'thought' && ( <> )} {kind === 'document' && ( )} {kind === 'list_item' && ( <> )}
)} {/* Results Container */}
{query.trim() === '' ? (

MindSift Globale Suche

Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.

) : flatHits.length === 0 && !loading ? (

Keine Treffer gefunden

Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.

) : ( groupedHits.map((group) => { const GroupIcon = group.icon; return (
{group.label}
{group.items.map((item) => { const overallIndex = flatHits.indexOf(item); const isSelected = overallIndex === selectedIndex; const task = item.type === 'thought' ? thoughtById.get(item.id) : undefined; const listItem = item.type === 'list_item' ? itemById.get(item.id) : undefined; const finished = Boolean(task?.isCompleted || listItem?.isCompleted); return (
executeSelection(item)} onMouseEnter={() => setSelectedIndex(overallIndex)} className={` px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border ${ isSelected ? 'bg-panel-elevated border-accent text-main' : 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated' } `} > {task || listItem ? ( // Tasks and list items: their real state, ticked off right here ) : (
{item.icon || '🗒️'}
)}
{item.title || 'Unbenannt'} {isSelected && ( Öffnen )}
{/* Large view: the bullet before the hit */} {expanded && item.before &&

{item.before}

} {item.snippet && (
)} {expanded && item.after &&

{item.after}

} {/* Large view: where a task or list item belongs and its state */} {expanded && task && (

{taskListTitle(task.taskListId) || 'Aufgabenliste'} {task.dueDate && fällig {formatDue(task.dueDate)}} {task.priority && task.priority !== 'normal' && {PRIORITY_LABELS[task.priority]}} {task.isCompleted && erledigt}

)} {expanded && listItem && (

{listTitle(listItem.listId) || 'Liste'} {listItem.isCompleted && abgehakt}

)}
); })}
); }) )}
{/* Footer Shortcut Bar (Desktop) */}
↑{' '} ↓ Navigieren ↵ Auswählen Tab Art wechseln Esc Schließen
{flatHits.length} Treffer
); };