eslint.config.js with the recommended JS/TypeScript rules and the React hooks rules; npm run lint runs ESLint and is part of the image build. Errors fail the build, the 24 exhaustive-deps findings stay warnings. It found two real bugs: TaskDetailModal and ManageColumnsModal called their hooks after 'if (!isOpen) return null', so the hook count changed whenever the dialog opened or closed. Both are now a thin wrapper plus a content component. Also removes unused imports and dead code (old task list share handlers, an unused realtime status, an unused rollup). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Search,
|
|
FileText,
|
|
CheckSquare,
|
|
ShoppingCart,
|
|
X,
|
|
Loader2,
|
|
CornerDownLeft,
|
|
} from 'lucide-react';
|
|
import { SearchHit } from '../lib/types';
|
|
import { api } from '../lib/api';
|
|
|
|
interface CommandPaletteProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
|
onOpenTask: (taskId: string) => void;
|
|
onOpenList: (listId: string) => void;
|
|
}
|
|
|
|
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
|
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
|
const parts = useMemo(() => {
|
|
if (!snippet) return [];
|
|
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
|
}, [snippet]);
|
|
|
|
return (
|
|
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
|
{parts.map((part, i) => {
|
|
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
|
const text = part.slice(6, -7);
|
|
return (
|
|
<mark
|
|
key={i}
|
|
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
|
>
|
|
{text}
|
|
</mark>
|
|
);
|
|
}
|
|
return <span key={i}>{part}</span>;
|
|
})}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
onOpenDocument,
|
|
onOpenTask,
|
|
onOpenList,
|
|
}) => {
|
|
const [query, setQuery] = useState('');
|
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const debounceTimerRef = useRef<number | null>(null);
|
|
|
|
// Group hits by category: Notizen, Aufgaben, Listen
|
|
const groupedHits = useMemo(() => {
|
|
const docs: SearchHit[] = [];
|
|
const tasks: SearchHit[] = [];
|
|
const lists: 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') {
|
|
lists.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: lists },
|
|
].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]);
|
|
|
|
// Focus input and reset on open
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setQuery('');
|
|
setHits([]);
|
|
setSelectedIndex(0);
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// 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 res = await api.searchAll(trimmed);
|
|
setHits(res.hits || []);
|
|
setSelectedIndex(0);
|
|
} catch {
|
|
setHits([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, 150);
|
|
|
|
return () => {
|
|
if (debounceTimerRef.current !== null) {
|
|
window.clearTimeout(debounceTimerRef.current);
|
|
}
|
|
};
|
|
}, [query]);
|
|
|
|
// Keyboard navigation: ↑, ↓, Enter, Escape
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose();
|
|
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]);
|
|
|
|
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;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center p-0 sm:p-4 sm:pt-16 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={onClose}
|
|
>
|
|
{/* Search Modal Container */}
|
|
<div
|
|
className="
|
|
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
|
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
|
"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Top Input Bar */}
|
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-border bg-canvas/40 shrink-0">
|
|
<Search className="w-5 h-5 text-accent shrink-0" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => 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"
|
|
/>
|
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
|
{query && !loading && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setQuery('');
|
|
setHits([]);
|
|
inputRef.current?.focus();
|
|
}}
|
|
className="text-muted hover:text-main p-1"
|
|
title="Eingabe leeren"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="sm:hidden px-2 py-1 text-xs font-semibold text-accent"
|
|
>
|
|
Fertig
|
|
</button>
|
|
</div>
|
|
|
|
{/* Results Container */}
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
|
{query.trim() === '' ? (
|
|
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
|
<p className="font-semibold text-main">MindSift Globale Suche</p>
|
|
<p>Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.</p>
|
|
</div>
|
|
) : flatHits.length === 0 && !loading ? (
|
|
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
|
<p className="font-semibold text-main">Keine Treffer gefunden</p>
|
|
<p>Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.</p>
|
|
</div>
|
|
) : (
|
|
groupedHits.map((group) => {
|
|
const GroupIcon = group.icon;
|
|
return (
|
|
<div key={group.key} className="space-y-1">
|
|
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold text-accent uppercase tracking-wider">
|
|
<GroupIcon className="w-3.5 h-3.5" />
|
|
<span>{group.label}</span>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
{group.items.map((item) => {
|
|
const overallIndex = flatHits.indexOf(item);
|
|
const isSelected = overallIndex === selectedIndex;
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => 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'
|
|
}
|
|
`}
|
|
>
|
|
<div className="mt-0.5 shrink-0 text-base leading-none">
|
|
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 space-y-0.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="font-semibold text-xs truncate">
|
|
{item.title || 'Unbenannt'}
|
|
</span>
|
|
{isSelected && (
|
|
<span className="hidden sm:inline-flex items-center text-[10px] text-accent gap-0.5 shrink-0">
|
|
<span>Öffnen</span>
|
|
<CornerDownLeft className="w-3 h-3" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{item.snippet && (
|
|
<div>
|
|
<SnippetRenderer snippet={item.snippet} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer Shortcut Bar (Desktop) */}
|
|
<div className="hidden sm:flex items-center justify-between px-4 py-2 bg-canvas/40 border-t border-border text-[11px] text-muted shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<span>
|
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↑</kbd>{' '}
|
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↓</kbd> Navigieren
|
|
</span>
|
|
<span>
|
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
|
</span>
|
|
<span>
|
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<span>{flatHits.length} Treffer</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|