Paket B: Globale Suche mit SQLite FTS5, BM25-Ranking, Zugriffsprüfung und Cmd+K-Palette

This commit is contained in:
sandrodach committed 2026-10-28 23:00:19 +01:00
1 parent 633bd6f1ef
commit 71fa53ead3
10 files changed
+1030 -55

No files matched your search

+78 -7
View File
@@ -23,6 +23,7 @@ import {
Flame,
Mic,
MicOff,
Search,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
import {
@@ -55,6 +56,7 @@ import { GroupsView } from './features/teams/GroupsView';
import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette';
import { APP_VERSION } from './version';
type ActiveTab =
@@ -135,6 +137,8 @@ export const App: React.FC = () => {
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
@@ -593,6 +597,36 @@ export const App: React.FC = () => {
setActiveTab('tasks');
}, []);
const handleOpenList = (listId: string) => {
setSelectedListId(listId);
setActiveTab('lists');
};
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const el = e.target as HTMLElement;
const isEditing =
el.tagName === 'INPUT' ||
el.tagName === 'TEXTAREA' ||
el.isContentEditable;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setShowCommandPalette((prev) => !prev);
return;
}
if (!isEditing && e.key === '/') {
e.preventDefault();
setShowCommandPalette(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const handleTriggerAddButton = () => {
triggerHaptic(20);
if (window.innerWidth < 640) {
@@ -709,6 +743,19 @@ export const App: React.FC = () => {
</button>
)}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowCommandPalette(true);
}}
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
title="Globale Suche"
aria-label="Globale Suche öffnen"
>
<Search className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => {
@@ -749,12 +796,23 @@ export const App: React.FC = () => {
</div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="flex-1 max-w-xl mx-auto">
<InstantCaptureBar
onCaptured={handleCaptured}
lists={lists}
autoFocusTrigger={captureFocusCount}
/>
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1">
<InstantCaptureBar
onCaptured={handleCaptured}
lists={lists}
autoFocusTrigger={captureFocusCount}
/>
</div>
<button
type="button"
onClick={() => setShowCommandPalette(true)}
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
title="Globale Suche öffnen (Cmd+K oder /)"
>
<Search className="w-3.5 h-3.5" />
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
</button>
</div>
{/* User Profile Pill & Dropdown Menu */}
@@ -1020,7 +1078,11 @@ export const App: React.FC = () => {
)}
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
<SharedListsView
onRefreshGlobal={refreshAll}
currentUser={user}
initialListId={selectedListId}
/>
)}
{activeTab === 'groups' && (
@@ -1555,6 +1617,15 @@ export const App: React.FC = () => {
</div>
</div>
)}
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
<CommandPalette
isOpen={showCommandPalette}
onClose={() => setShowCommandPalette(false)}
onOpenDocument={handleOpenDocument}
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
/>
</div>
);
};
+327
View File
@@ -0,0 +1,327 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Search,
FileText,
CheckSquare,
ShoppingCart,
X,
Loader2,
ArrowRight,
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/25 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>
);
};
@@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
el.tagName === 'TEXTAREA' ||
el.isContentEditable;
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
inputRef.current?.focus();
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
@@ -19,6 +19,7 @@ import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
currentUser?: User | null;
initialListId?: string | null;
}
export function parseListItemInput(rawText: string, defaultQty?: string): {
@@ -78,10 +79,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
return result;
}
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
const { setGroups, setPrimaryAction } = useMobileActions();
const [lists, setLists] = useState<SharedList[]>([]);
const [activeListId, setActiveListId] = useState<string>('');
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
useEffect(() => {
if (initialListId) {
setActiveListId(initialListId);
}
}, [initialListId]);
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
@@ -127,7 +134,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
items: deduplicateItems(l.items || []),
}));
setLists(deduplicated);
if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
setActiveListId(initialListId);
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
setActiveListId(deduplicated[0].id);
}
} catch {
+3 -2
View File
@@ -24,6 +24,7 @@ import {
TaskListShare,
Thought,
SubTask,
SearchResults,
TagDetails,
TagSummary,
User,
@@ -1059,8 +1060,8 @@ export const api = {
);
},
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
return request<{ thoughts: Thought[]; documents: Document[] }>(
async searchAll(q: string): Promise<SearchResults> {
return request<SearchResults>(
`/api/v1/search?q=${encodeURIComponent(q)}`
);
},
+19
View File
@@ -297,6 +297,25 @@ export interface TagDetails {
totalMatches: number;
}
export interface SearchHit {
type: 'document' | 'thought' | 'list_item';
id: string;
title: string;
snippet: string;
score?: number;
docId?: string;
blockId?: string;
listId?: string;
icon?: string;
}
export interface SearchResults {
thoughts: Thought[];
documents: Document[];
listItems: ListItem[];
hits: SearchHit[];
}
export interface SpaceMember {
spaceId: string;
userId: string;