disclosure-bureau/web/components/search-panel.tsx
Luiz Gustavo babffda882
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 36s
CI / Scripts — Python smoke (push) Failing after 6s
CI / Web — npm audit (push) Failing after 44s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 13s
ux(search): redesign /search from dev-tool to magazine
The audit's most ostensive gap: /search rendered as a green-on-black
terminal panel with a tech-jargon eyebrow ("HYBRID SEARCH · BM25 + BGE-M3
DENSE + CROSS-ENCODER RERANK"), no global navbar, and a floating locale
toggle disconnected from the rest of the site. Readers landing here saw
a different product.

Page (app/search/page.tsx):
- Render SiteHeader at the top (same primary nav as home; "Search" now
  picks up the gold active-page indicator).
- Magazine hero: eyebrow + serif headline ("Find a passage, a witness, a
  date." / "Encontre uma passagem, uma testemunha, uma data.") + lead.
- generateMetadata() reads getLocale() so the tab title localises too.

Panel (search-panel.tsx):
- Drop matrix-green palette; switch to magazine gold/cream.
- Rounded input with magnifier icon, gold solid submit pill.
- Plain-language placeholder, no developer terms.
- "Advanced filters" toggle: passage-type + exact doc-id behind a
  collapsible — default state is one clean search box.
- Localise passage-type labels (paragraph → parágrafo, heading → título,
  classification_marking → marcação de classificação, etc).
- Result card palette switched to gold/cream + clearer page · type ·
  classification line; raw retrieval score removed from the UI (debug
  noise for users).
- Locale toggle inside the form gone — the navbar carries it now.

Follow-ups noted (not in this commit):
- SearchAutocomplete dropdown still uses the old terminal palette.
- Navbar wraps on narrow mobile widths and the rightmost links overflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:41 -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("")} />
</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>
);
}