'use client'; import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Input } from '@/components/ui/input'; import { Search, FolderOpen, Building2, Video, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; interface ProjectResult { id: string; name: string; description: string | null; workspace: { id: string; name: string }; } interface WorkspaceResult { id: string; name: string; description: string | null; } interface VideoResult { id: string; title: string; projectId: string; project: { id: string; name: string }; } interface SearchResults { projects: ProjectResult[]; workspaces: WorkspaceResult[]; videos: VideoResult[]; } type ResultItem = | { kind: 'project'; data: ProjectResult } | { kind: 'workspace'; data: WorkspaceResult } | { kind: 'video'; data: VideoResult }; interface SearchModalProps { open: boolean; onOpenChange: (open: boolean) => void; } function scoreMatch(name: string, term: string): number { const n = name.toLowerCase(); const t = term.toLowerCase(); if (n === t) return 3; if (n.startsWith(t)) return 2; return 1; } function buildFlatList(results: SearchResults, term: string): ResultItem[] { const projects: ResultItem[] = results.projects .map((p) => ({ kind: 'project' as const, data: p, score: scoreMatch(p.name, term) })) .sort((a, b) => b.score - a.score) .map(({ kind, data }) => ({ kind, data })); const workspaces: ResultItem[] = results.workspaces .map((w) => ({ kind: 'workspace' as const, data: w, score: scoreMatch(w.name, term) })) .sort((a, b) => b.score - a.score) .map(({ kind, data }) => ({ kind, data })); const videos: ResultItem[] = results.videos .map((v) => ({ kind: 'video' as const, data: v, score: scoreMatch(v.title, term) })) .sort((a, b) => b.score - a.score) .map(({ kind, data }) => ({ kind, data })); return [...projects, ...workspaces, ...videos]; } function getItemHref(item: ResultItem): string { if (item.kind === 'project') return `/projects/${item.data.id}`; if (item.kind === 'workspace') return `/workspaces/${item.data.id}`; return `/projects/${item.data.projectId}/videos/${item.data.id}`; } function getItemLabel(item: ResultItem): string { if (item.kind === 'project') return item.data.name; if (item.kind === 'workspace') return item.data.name; return item.data.title; } function getItemSub(item: ResultItem): string | null { if (item.kind === 'project') return item.data.workspace.name; if (item.kind === 'workspace') return item.data.description ?? null; return item.data.project.name; } const CATEGORY_LABELS: Record = { project: 'Projects', workspace: 'Workspaces', video: 'Videos', }; const CategoryIcon: Record> = { project: FolderOpen, workspace: Building2, video: Video, }; export function SearchModal({ open, onOpenChange }: SearchModalProps) { const router = useRouter(); const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); const [activeIdx, setActiveIdx] = useState(0); const debounceRef = useRef | null>(null); const inputRef = useRef(null); const abortRef = useRef(null); // Reset when modal opens/closes useEffect(() => { if (!open) { setQuery(''); setResults(null); setActiveIdx(0); } else { setTimeout(() => inputRef.current?.focus(), 0); } }, [open]); const doSearch = useCallback(async (term: string) => { if (term.length < 2) { setResults(null); setLoading(false); return; } // Cancel previous request abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setLoading(true); try { const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { signal: controller.signal, }); if (!res.ok) throw new Error('Search failed'); const json = await res.json(); setResults(json.data); setActiveIdx(0); } catch (err: unknown) { if (err instanceof Error && err.name !== 'AbortError') { setResults(null); } } finally { if (!controller.signal.aborted) setLoading(false); } }, []); const handleChange = useCallback( (e: React.ChangeEvent) => { const val = e.target.value; setQuery(val); if (debounceRef.current) clearTimeout(debounceRef.current); if (val.trim().length < 2) { abortRef.current?.abort(); setResults(null); setLoading(false); return; } setLoading(true); debounceRef.current = setTimeout(() => doSearch(val.trim()), 300); }, [doSearch] ); const flatList = useMemo( () => (results ? buildFlatList(results, query.trim()) : []), [results, query] ); const handleSelect = useCallback( (item: ResultItem) => { onOpenChange(false); router.push(getItemHref(item)); }, [onOpenChange, router] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (flatList.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, flatList.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const item = flatList[activeIdx]; if (item) handleSelect(item); } }, [flatList, activeIdx, handleSelect] ); const isEmpty = !loading && results !== null && flatList.length === 0; const showInitial = !loading && results === null && query.length < 2; // Group flat list by category for section headers const sections: { kind: ResultItem['kind']; items: ResultItem[]; startIdx: number }[] = []; let cursor = 0; for (const kind of ['project', 'workspace', 'video'] as const) { const items = flatList.filter((i) => i.kind === kind); if (items.length > 0) { sections.push({ kind, items, startIdx: cursor }); cursor += items.length; } } return ( Search {/* Single flex-col wrapper keeps the grid from producing a stray gap row */}
{/* Search input */}
{loading && }
{/* Results */}
{showInitial && (

Type at least 2 characters to search.

)} {isEmpty && (

No results for “{query}”

)} {sections.map(({ kind, items, startIdx }) => { const Icon = CategoryIcon[kind]; return (
{CATEGORY_LABELS[kind]}
{items.map((item, localIdx) => { const globalIdx = startIdx + localIdx; const label = getItemLabel(item); const sub = getItemSub(item); const isActive = globalIdx === activeIdx; return ( ); })}
); })} {sections.length > 0 && (
)}
{/* Footer hint */}
navigate open Esc close
{/* end flex-col wrapper */}
); }