/** * SearchPanel — full hybrid_search page with form controls and rich result cards. * * Magazine palette (gold + cream), localised copy, no developer jargon. The * advanced filters (chunk type, exact doc-id) are tucked behind a toggle so * the default state is one clean search input. Each result card shows the * bbox-cropped image plus snippet so the reader sees a piece of the original * page, not just the text. * * Syncs URL params so results are shareable. */ "use client"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Search as SearchIcon, SlidersHorizontal } from "lucide-react"; import { SearchAutocomplete } from "./search-autocomplete"; interface Hit { chunk_id: string; doc_id: string; page: number; type: string; bbox: { x: number; y: number; w: number; h: number } | null; classification: string | null; snippet: string; score: number; href: string; } const COPY = { "pt-br": { placeholder: "ex.: objetos esféricos sobre Kansas, MJ-12, Tic-Tac do Nimitz…", search: "Buscar", searching: "Buscando…", advanced: "Filtros avançados", type_label: "Tipo de trecho", type_any: "qualquer", doc_label: "Restringir a um documento", doc_placeholder: "doc-id exato (opcional)", empty: "Nenhum resultado para essa busca.", error_prefix: "Busca indisponível: ", types_map: { paragraph: "parágrafo", heading: "título", image: "imagem", stamp: "carimbo", signature: "assinatura", table_marker: "tabela", address_block: "endereço", form_field: "formulário", classification_marking: "marcação de classificação", redaction: "redação", } as Record, }, en: { placeholder: "e.g. spherical objects over Kansas, MJ-12, Nimitz Tic-Tac…", search: "Search", searching: "Searching…", advanced: "Advanced filters", type_label: "Passage type", type_any: "any", doc_label: "Limit to one document", doc_placeholder: "exact doc-id (optional)", empty: "No results for this search.", error_prefix: "Search unavailable: ", types_map: { paragraph: "paragraph", heading: "heading", image: "image", stamp: "stamp", signature: "signature", table_marker: "table", address_block: "address block", form_field: "form field", classification_marking: "classification marking", redaction: "redaction", } as Record, }, } as const; export function SearchPanel({ locale = "pt-br", initialQ, initialLang, initialType, initialDocId, }: { locale?: "pt-br" | "en"; initialQ: string; initialLang: "pt" | "en"; initialType: string; initialDocId: string; }) { const router = useRouter(); const params = useSearchParams(); const t = COPY[locale]; const [q, setQ] = useState(initialQ); const [type, setType] = useState(initialType); const [docId, setDocId] = useState(initialDocId); const [hits, setHits] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(Boolean(initialType || initialDocId)); // Initial fetch if URL had params useEffect(() => { if (initialQ) doSearch(initialQ, initialLang, initialType, initialDocId); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function doSearch(qStr: string, l: "pt" | "en", typ: string, d: string) { if (!qStr.trim()) return; setLoading(true); setError(null); const sp = new URLSearchParams({ q: qStr, lang: l, top_k: "25" }); if (typ) sp.set("type", typ); if (d) sp.set("doc_id", d); try { const r = await fetch(`/api/search/hybrid?${sp}`); if (!r.ok) { const j = await r.json().catch(() => ({})); setError(j.message ?? `HTTP ${r.status}`); setHits([]); return; } const j = (await r.json()) as { hits?: Hit[] }; setHits(j.hits ?? []); } catch (e) { setError((e as Error).message); setHits([]); } finally { setLoading(false); } } function submit(e: React.FormEvent) { e.preventDefault(); const sp = new URLSearchParams(params.toString()); sp.set("q", q); sp.set("lang", initialLang); if (type) sp.set("type", type); else sp.delete("type"); if (docId) sp.set("doc_id", docId); else sp.delete("doc_id"); router.replace(`/search?${sp}`); doSearch(q, initialLang, type, docId); } const PASSAGE_TYPES = [ "paragraph", "heading", "image", "stamp", "signature", "table_marker", "address_block", "form_field", "classification_marking", "redaction", ]; return (
setQ(e.target.value)} placeholder={t.placeholder} aria-label={t.search} className="w-full bg-[#0a0e1a] border border-[rgba(224,192,128,0.25)] focus:border-[#e0c080] rounded-full pl-10 pr-4 py-3 font-display text-[15px] md:text-[16px] text-[#e7ecf3] placeholder:text-[#5a6678] outline-none transition-colors" autoFocus /> setQ("")} />
{advancedOpen && (
setDocId(e.target.value)} placeholder={t.doc_placeholder} className="w-full bg-[#0a0e1a] border border-[rgba(224,192,128,0.25)] focus:border-[#e0c080] rounded px-3 py-2 font-mono text-[12px] text-[#e7ecf3] placeholder:text-[#5a6678] outline-none" />
)}
{error && (
{t.error_prefix}{error}
)}
{hits.map((h) => { const cropUrl = h.bbox ? `/api/crop?doc=${encodeURIComponent(h.doc_id)}&page=${h.page}` + `&x=${h.bbox.x}&y=${h.bbox.y}&w=${h.bbox.w}&h=${h.bbox.h}&w_px=320` : null; const typeLabel = t.types_map[h.type] ?? h.type; return ( {cropUrl && ( )}
p{h.page} {typeLabel} {h.classification && ( <> {h.classification} )}

{h.snippet}

{h.doc_id}
); })}
{!loading && !error && initialQ && hits.length === 0 && (
{t.empty}
)}
); }