"use client"; /** * SearchAutocomplete — type-as-you-go dropdown on the /search input. * * Hits /api/search/autocomplete (Meilisearch) with debounced fetch and renders * a two-section dropdown: matching documents (jump targets) and matching * chunks (in-doc passages with excerpt). Sub-30ms target. Keyboard navigation * via Up/Down + Enter. Esc closes. * * Magazine palette (gold/cream on dark) to match the rest of the site; the * previous matrix-green/cyan styling was a leftover from the dev-tool era. */ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Zap } from "lucide-react"; interface DocSuggestion { doc_id: string; title: string; collection?: string; href: string; } interface ChunkSuggestion { chunk_id: string; doc_id: string; page: number; type: string; excerpt: string; ufo_anomaly: boolean; href: string; } interface ApiResponse { q: string; duration_ms?: number; documents: DocSuggestion[]; chunks: ChunkSuggestion[]; } const COPY = { "pt-br": { autocomplete: "Sugestões", documents: "Documentos", passages: "Trechos", docs_count: (n: number) => `${n} ${n === 1 ? "documento" : "documentos"}`, chunks_count: (n: number) => `${n} ${n === 1 ? "trecho" : "trechos"}`, }, en: { autocomplete: "Suggestions", documents: "Documents", passages: "Passages", docs_count: (n: number) => `${n} ${n === 1 ? "document" : "documents"}`, chunks_count: (n: number) => `${n} ${n === 1 ? "passage" : "passages"}`, }, } as const; export function SearchAutocomplete({ query, onPick, locale = "pt-br", }: { query: string; onPick?: () => void; locale?: "pt-br" | "en"; }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const timer = useRef | null>(null); const abort = useRef(null); const t = COPY[locale]; useEffect(() => { const q = query.trim(); if (q.length < 2) { setData(null); setOpen(false); return; } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(async () => { abort.current?.abort(); abort.current = new AbortController(); setLoading(true); try { const r = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(q)}`, { signal: abort.current.signal, }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = (await r.json()) as ApiResponse; setData(j); setOpen(j.documents.length + j.chunks.length > 0); } catch (e) { if ((e as Error).name === "AbortError") return; setData(null); setOpen(false); } finally { setLoading(false); } }, 150); return () => { if (timer.current) clearTimeout(timer.current); }; }, [query]); if (!open || !data) return null; return (
{loading ? "…" : `${data.duration_ms ?? "?"}ms`}
{data.documents.length > 0 && (
{t.documents}
    {data.documents.map((d) => (
  • {d.title}
    {d.doc_id} {d.collection && <>{d.collection}}
  • ))}
)} {data.chunks.length > 0 && (
{t.passages}
    {data.chunks.map((c) => (
  • p{c.page} {c.type} {c.ufo_anomaly && 🛸} {c.doc_id}
    {c.excerpt}
  • ))}
)}
); }