disclosure-bureau/web/components/search-panel.tsx
Luiz Gustavo 0a4c319a14
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 47s
CI / Scripts — Python smoke (push) Failing after 9s
CI / Web — npm audit (push) Failing after 34s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 6s
ux(search): repaint autocomplete dropdown to magazine palette
The /search input opened a green/cyan terminal-style dropdown leftover from
the dev-tool era — the only piece of the search experience still off-theme
after the page redesign.

- Switch matrix-green (#00ff9c) and cyan (#7fdbff) for gold (#e0c080) and
  cream throughout the dropdown.
- Replace ASCII "" with a Zap lucide icon and reorder the header so the
  Suggestions label leads.
- Localise "documentos" / "trechos" / "autocomplete" via a locale prop
  threaded from SearchPanel; counts pluralise per language.
- Document title now uses the display serif at 15px (was monospace small);
  passage excerpts in #cbd2dd to match the rest of body copy.
- Passage row reorder: page · type · 🛸 · doc-id (was chunk_id first);
  page number gets gold accent so the reader can scan by page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 01:34:15 -03:00

292 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string>,
},
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<string, string>,
},
} 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<Hit[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div>
<form
onSubmit={submit}
className="mb-10 rounded-2xl border border-[rgba(224,192,128,0.20)] bg-[rgba(224,192,128,0.03)] p-4 md:p-5"
>
<div className="relative">
<div className="flex items-stretch gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#9aa6b8]">
<SearchIcon size={16} aria-hidden="true" />
</span>
<input
value={q}
onChange={(e) => 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
/>
<SearchAutocomplete query={q} onPick={() => setQ("")} locale={locale} />
</div>
<button
type="submit"
disabled={!q.trim() || loading}
className="px-5 md:px-6 py-3 font-mono text-[12px] uppercase tracking-widest border border-[#e0c080] text-[#0a0e1a] bg-[#e0c080] hover:bg-[#f0d4a0] rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? t.searching : t.search}
</button>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
className="inline-flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-wider text-[#9aa6b8] hover:text-[#e0c080] transition-colors"
>
<SlidersHorizontal size={12} aria-hidden="true" />
<span>{t.advanced}</span>
<span aria-hidden="true">{advancedOpen ? "" : "+"}</span>
</button>
</div>
{advancedOpen && (
<div className="mt-4 grid md:grid-cols-2 gap-4 pt-4 border-t border-[rgba(224,192,128,0.15)]">
<div>
<label className="font-mono text-[10px] uppercase tracking-widest text-[#9aa6b8] block mb-1.5">
{t.type_label}
</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
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] outline-none"
>
<option value="">{t.type_any}</option>
{PASSAGE_TYPES.map((typeKey) => (
<option key={typeKey} value={typeKey}>
{t.types_map[typeKey] ?? typeKey}
</option>
))}
</select>
</div>
<div>
<label className="font-mono text-[10px] uppercase tracking-widest text-[#9aa6b8] block mb-1.5">
{t.doc_label}
</label>
<input
value={docId}
onChange={(e) => 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"
/>
</div>
</div>
)}
</form>
{error && (
<div className="mb-6 p-3 border border-[rgba(255,107,107,0.30)] bg-[rgba(255,107,107,0.05)] rounded font-mono text-[12px] text-[#ff6b6b]">
{t.error_prefix}{error}
</div>
)}
<div className="space-y-3">
{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 (
<Link
key={`${h.doc_id}-${h.chunk_id}`}
href={h.href}
className="block p-4 bg-[#0d1220] border border-[rgba(224,192,128,0.18)] hover:border-[#e0c080] rounded-xl transition-colors flex gap-4"
>
{cropUrl && (
<Image
src={cropUrl}
alt=""
width={160}
height={100}
className="block w-40 h-24 object-cover bg-black rounded-md flex-shrink-0"
unoptimized
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wider mb-1.5">
<span className="text-[#e0c080]">p{h.page}</span>
<span className="text-[#5a6678]" aria-hidden="true">·</span>
<span className="text-[#9aa6b8]">{typeLabel}</span>
{h.classification && (
<>
<span className="text-[#5a6678]" aria-hidden="true">·</span>
<span className="text-[#ff9696]">{h.classification}</span>
</>
)}
</div>
<p className="text-[#e7ecf3] text-[14px] leading-relaxed line-clamp-3 mb-1.5">{h.snippet}</p>
<div className="text-[10px] font-mono text-[#5a6678] truncate">{h.doc_id}</div>
</div>
</Link>
);
})}
</div>
{!loading && !error && initialQ && hits.length === 0 && (
<div className="text-center text-[#5a6678] font-mono text-[13px] py-12">
{t.empty}
</div>
)}
</div>
);
}