Phase 4.1: AI Writing Assistant - GPT-4o integration with OpenAI API (fallback to local parsing) - Six AI actions: summarize, expand, rewrite, extract actions, suggest links, smart schedule - TipTap inline AI assistant panel with streaming-ready architecture - Apply/dismiss UI for AI suggestions - Rewrite style selector (concise, formal, simpler, detailed) Phase 4.2: Natural Language Input - /api/v1/natural-language endpoint for parsing unstructured text - AI-powered extraction of type, title, date, priority, participants - Local regex fallback when no API key configured - Priority parsing (!1-5) and date detection (tomorrow, next week) Phase 4.3: Knowledge Graph - D3.js-ready graph visualization container - Zoom in/out/fit controls - Placeholder state with guidance for content creation - Data model for nodes, links, and groups Phase 4.4: Semantic Search Infrastructure - Unified search bar with debounced input (300ms) - Type-based result icons (note, task, event) - Search result display with title and content preview - pgvector and embedding pipeline data model in schema
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
import { Search, Loader2, FileText, CheckSquare, Calendar } from 'lucide-react';
|
|
import { useDebounce } from '@yetanother/hooks';
|
|
|
|
interface SearchResult {
|
|
id: string;
|
|
title: string;
|
|
type: string;
|
|
plainText: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export function SearchBar() {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const debouncedQuery = useDebounce(query, 300);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleSearch = useCallback(async (q: string) => {
|
|
if (!q.trim()) { setResults([]); return; }
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/search?q=${encodeURIComponent(q)}`);
|
|
const data = await res.json() as { results: SearchResult[] };
|
|
setResults(data.results);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useState(() => {
|
|
if (debouncedQuery) handleSearch(debouncedQuery);
|
|
});
|
|
|
|
const typeIcons: Record<string, React.ReactNode> = {
|
|
note: <FileText className="size-4" />,
|
|
task: <CheckSquare className="size-4" />,
|
|
event: <Calendar className="size-4" />,
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 200)}
|
|
placeholder="Search anything..."
|
|
className="w-64 pl-9 pr-3 py-2 text-sm rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
{loading && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
|
|
{open && results.length > 0 && (
|
|
<div className="absolute top-full mt-1 w-full bg-popover border rounded-lg shadow-lg z-50 max-h-80 overflow-y-auto">
|
|
{results.map((r) => (
|
|
<button
|
|
key={r.id}
|
|
type="button"
|
|
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-accent transition-colors border-b last:border-0"
|
|
>
|
|
<span className="mt-0.5 text-muted-foreground">
|
|
{typeIcons[r.type] ?? <FileText className="size-4" />}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{r.title || 'Untitled'}</div>
|
|
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
|
{r.plainText?.slice(0, 100)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|