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>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|