feat(search): kind chips, large view with filters and context, tick tasks and list items right in the results
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m37s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 18:53:10 +01:00
1 parent 558fc46cf4
commit 994f6dc554
12 files changed
+522 -40

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.49.2",
"version": "0.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.49.2",
"version": "0.50.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.49.2",
"version": "0.50.0",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
+1
View File
@@ -1896,6 +1896,7 @@ export const App: React.FC = () => {
onOpenDocument={handleOpenDocument}
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
lists={lists}
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
+291 -15
View File
@@ -7,9 +7,13 @@ import {
X,
Loader2,
CornerDownLeft,
Maximize2,
Minimize2,
Check,
} from 'lucide-react';
import { SearchHit } from '../lib/types';
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 {
@@ -18,8 +22,26 @@ interface CommandPaletteProps {
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<string, string> = { 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<CommandPaletteProps> = ({
isOpen,
@@ -27,11 +49,22 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
onOpenDocument,
onOpenTask,
onOpenList,
lists = [],
}) => {
const [query, setQuery] = useState('');
const [hits, setHits] = useState<SearchHit[]>([]);
const [thoughts, setThoughts] = useState<Thought[]>([]);
const [listItems, setListItems] = useState<ListItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [kind, setKind] = useState<Kind>('all');
// Large view (desktop): bigger window, filters per kind and context for every hit
const [expanded, setExpanded] = useState(false);
const [filter, setFilter] = useState<SearchFilter>({});
// 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<TaskList[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<number | null>(null);
@@ -40,7 +73,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
const groupedHits = useMemo(() => {
const docs: SearchHit[] = [];
const tasks: SearchHit[] = [];
const lists: SearchHit[] = [];
const listHits: SearchHit[] = [];
for (const h of hits) {
if (h.type === 'document') {
@@ -48,14 +81,14 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
} else if (h.type === 'thought') {
tasks.push(h);
} else if (h.type === 'list_item') {
lists.push(h);
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: lists },
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: listHits },
].filter((g) => g.items.length > 0);
}, [hits]);
@@ -64,16 +97,50 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
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) {
@@ -91,8 +158,18 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
setLoading(true);
debounceTimerRef.current = window.setTimeout(async () => {
try {
const res = await api.searchAll(trimmed);
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([]);
@@ -106,9 +183,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
window.clearTimeout(debounceTimerRef.current);
}
};
}, [query]);
// counts is only read to skip a second request
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, activeFilter]);
// Keyboard navigation: ↑, ↓, Enter, Escape
// Keyboard navigation: ↑, ↓, Enter, Escape; Tab and Strg+1–4 switch the kind
useEffect(() => {
if (!isOpen) return;
@@ -119,6 +198,20 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
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') {
@@ -140,6 +233,31 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
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') {
@@ -155,6 +273,18 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
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 (
<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"
@@ -162,10 +292,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
>
{/* 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
"
className={`w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl sm:rounded-card sm:border sm:border-border ${
expanded ? 'sm:h-[85vh] sm:max-w-5xl' : 'sm:h-auto sm:max-h-[80vh] sm:max-w-2xl'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Top Input Bar */}
@@ -198,6 +327,21 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
</button>
) : null}
</span>
{/* Large view with filters (desktop; on phones the search fills the screen anyway) */}
<button
type="button"
onClick={() => {
setExpanded((v) => !v);
if (expanded) setFilter({});
inputRef.current?.focus();
}}
className="hidden sm:flex w-7 h-7 items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated shrink-0"
title={expanded ? 'Kleine Ansicht' : 'Große Ansicht mit Filtern'}
aria-label={expanded ? 'Kleine Ansicht' : 'Große Ansicht mit Filtern'}
aria-pressed={expanded}
>
{expanded ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
<button
type="button"
onClick={onClose}
@@ -207,6 +351,96 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
</button>
</div>
{/* Kinds as chips, with the number of hits */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border overflow-x-auto shrink-0" role="tablist" aria-label="Art der Treffer">
{KINDS.map((k) => {
const active = kind === k.kind;
const n = countLabel(k.kind);
return (
<button
key={k.kind}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setKind(k.kind);
inputRef.current?.focus();
}}
className={`h-9 sm:h-7 px-3 rounded-pill border text-xs flex items-center gap-1.5 shrink-0 transition-colors ${
active ? 'bg-accent/10 border-accent text-main font-semibold' : 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>{k.label}</span>
{n !== null && <span className="text-[10px] font-mono opacity-70">{n}</span>}
</button>
);
})}
</div>
{/* Large view: filters of the chosen kind */}
{expanded && kind !== 'all' && (
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-border bg-canvas/40 shrink-0 text-xs text-muted">
{kind === 'thought' && (
<>
<select className={selectClass} value={filter.taskStatus || ''} onChange={(e) => setFilterValue('taskStatus', e.target.value)} aria-label="Status">
<option value="">Offen und erledigt</option>
<option value="open">Nur offene</option>
<option value="done">Nur erledigte</option>
</select>
<select className={selectClass} value={filter.taskListId || ''} onChange={(e) => setFilterValue('taskListId', e.target.value)} aria-label="Aufgabenliste">
<option value="">Alle Listen</option>
<option value="personal">Persönlich (ohne Liste)</option>
{taskLists.map((l) => (
<option key={l.id} value={l.id}>
{[l.icon, l.title].filter(Boolean).join(' ')}
</option>
))}
</select>
<select className={selectClass} value={filter.priority || ''} onChange={(e) => setFilterValue('priority', e.target.value)} aria-label="Priorität">
<option value="">Jede Priorität</option>
<option value="urgent">Dringend</option>
<option value="high">Wichtig</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
<select className={selectClass} value={filter.due || ''} onChange={(e) => setFilterValue('due', e.target.value)} aria-label="Fälligkeit">
<option value="">Jede Fälligkeit</option>
<option value="overdue">Überfällig</option>
<option value="today">Heute fällig</option>
<option value="week">Fällig in 7 Tagen</option>
</select>
</>
)}
{kind === 'document' && (
<select className={selectClass} value={filter.folder || ''} onChange={(e) => setFilterValue('folder', e.target.value)} aria-label="Ordner">
<option value="">Alle Ordner</option>
{folders.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
)}
{kind === 'list_item' && (
<>
<select className={selectClass} value={filter.listId || ''} onChange={(e) => setFilterValue('listId', e.target.value)} aria-label="Liste">
<option value="">Alle Listen</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{[l.icon, l.title].filter(Boolean).join(' ')}
</option>
))}
</select>
<select className={selectClass} value={filter.itemStatus || ''} onChange={(e) => setFilterValue('itemStatus', e.target.value)} aria-label="Status">
<option value="">Offen und abgehakt</option>
<option value="open">Nur offene</option>
<option value="done">Nur abgehakte</option>
</select>
</>
)}
</div>
)}
{/* Results Container */}
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
{query.trim() === '' ? (
@@ -233,6 +467,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
{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 (
<div
key={item.id}
@@ -247,13 +484,30 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
}
`}
>
<div className="mt-0.5 shrink-0 text-base leading-none">
{item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')}
</div>
{task || listItem ? (
// Tasks and list items: their real state, ticked off right here
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleDone(item);
}}
className={`mt-0.5 w-4 h-4 shrink-0 rounded-sm border flex items-center justify-center transition-colors before:absolute before:-inset-3 relative ${
finished ? 'bg-status-ok border-status-ok text-on-accent' : 'border-strong hover:border-accent text-transparent'
}`}
title={finished ? 'Wieder öffnen' : 'Abhaken'}
aria-label={finished ? 'Wieder öffnen' : 'Abhaken'}
aria-pressed={finished}
>
<Check className="w-3 h-3 stroke-[3]" />
</button>
) : (
<div className="mt-0.5 shrink-0 text-base leading-none">{item.icon || '🗒️'}</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">
<span className={`font-semibold text-xs truncate ${finished ? 'line-through text-muted' : ''}`}>
{item.title || 'Unbenannt'}
</span>
{isSelected && (
@@ -264,11 +518,30 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
)}
</div>
{/* Large view: the bullet before the hit */}
{expanded && item.before && <p className="text-[11px] text-faint truncate">{item.before}</p>}
{item.snippet && (
<div>
<SnippetRenderer snippet={item.snippet} />
</div>
)}
{expanded && item.after && <p className="text-[11px] text-faint truncate">{item.after}</p>}
{/* Large view: where a task or list item belongs and its state */}
{expanded && task && (
<p className="text-[11px] text-faint flex flex-wrap gap-x-2">
<span>{taskListTitle(task.taskListId) || 'Aufgabenliste'}</span>
{task.dueDate && <span>fällig {formatDue(task.dueDate)}</span>}
{task.priority && task.priority !== 'normal' && <span>{PRIORITY_LABELS[task.priority]}</span>}
{task.isCompleted && <span>erledigt</span>}
</p>
)}
{expanded && listItem && (
<p className="text-[11px] text-faint flex flex-wrap gap-x-2">
<span>{listTitle(listItem.listId) || 'Liste'}</span>
{listItem.isCompleted && <span>abgehakt</span>}
</p>
)}
</div>
</div>
);
@@ -290,6 +563,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
<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">Tab</kbd> Art wechseln
</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>
+7 -4
View File
@@ -35,6 +35,7 @@ import {
Thought,
SubTask,
SearchResults,
SearchFilter,
TagDetails,
TagSummary,
User,
@@ -1340,10 +1341,12 @@ export const api = {
});
},
async searchAll(q: string): Promise<SearchResults> {
return request<SearchResults>(
`/api/v1/search?q=${encodeURIComponent(q)}`
);
async searchAll(q: string, filter: SearchFilter = {}): Promise<SearchResults> {
const params = new URLSearchParams({ q });
for (const [k, v] of Object.entries(filter)) {
if (v !== undefined && v !== '') params.set(k, String(v));
}
return request<SearchResults>(`/api/v1/search?${params.toString()}`);
},
async exportAll(): Promise<any> {
+17
View File
@@ -511,6 +511,22 @@ export interface SearchHit {
blockId?: string;
listId?: string;
icon?: string;
// Bullets: the bullet before and after, for context
before?: string;
after?: string;
}
/** Narrowing of the global search (GET /search); empty fields do not filter. */
export interface SearchFilter {
type?: 'document' | 'thought' | 'list_item';
limit?: number;
taskStatus?: 'open' | 'done';
taskListId?: string; // 'personal' = without list
priority?: string;
due?: 'today' | 'overdue' | 'week';
folder?: string;
listId?: string;
itemStatus?: 'open' | 'done';
}
export interface SearchResults {
@@ -518,6 +534,7 @@ export interface SearchResults {
documents: Document[];
listItems: ListItem[];
hits: SearchHit[];
counts?: Partial<Record<'document' | 'thought' | 'list_item', number>>;
}
export interface SpaceMember {