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>
This commit is contained in:
parent
2ea350e283
commit
babffda882
2 changed files with 212 additions and 122 deletions
|
|
@ -1,44 +1,77 @@
|
||||||
/**
|
/**
|
||||||
* /search?q=...&lang=pt&type=...&doc_id=... — URL-shareable hybrid search results.
|
* /search?q=...&type=...&doc_id=... — bookmarkable hybrid search.
|
||||||
*
|
*
|
||||||
* Same retrieval pipeline as the Cmd+K palette, but on a full page with richer
|
* Magazine-style page: same global SiteHeader as the rest of the site, an
|
||||||
* cards (bbox crop, classification badge, full snippet). Bookmarkable.
|
* editorial hero that says in plain language what this page is, and a
|
||||||
|
* SearchPanel below. No tech jargon ("BM25", "BGE-M3", "rerank") visible to
|
||||||
|
* the reader — that detail is for engineers, not enthusiasts.
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
import type { Metadata } from "next";
|
||||||
import { AuthBar } from "@/components/auth-bar";
|
|
||||||
import { SearchPanel } from "@/components/search-panel";
|
import { SearchPanel } from "@/components/search-panel";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { getLocale } from "@/components/locale-toggle";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const title = locale === "en" ? "Search the archive" : "Buscar no arquivo";
|
||||||
|
const desc = locale === "en"
|
||||||
|
? "Search 28,000+ passages from declassified UAP/UFO documents. Verbatim quotes, page-level citations, bbox crops."
|
||||||
|
: "Busque em mais de 28 mil passagens dos documentos UAP/UFO desclassificados. Citações verbatim, referências por página, recortes em bbox.";
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: desc,
|
||||||
|
alternates: { canonical: `${SITE_URL}/search` },
|
||||||
|
openGraph: { title, description: desc, url: `${SITE_URL}/search` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SearchPage({
|
export default async function SearchPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; lang?: string; type?: string; doc_id?: string }>;
|
searchParams: Promise<{ q?: string; lang?: string; type?: string; doc_id?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
||||||
|
const lang: "pt" | "en" =
|
||||||
|
(sp.lang as "pt" | "en") ?? (locale === "en" ? "en" : "pt");
|
||||||
|
|
||||||
|
const heroEyebrow = locale === "en" ? "Search the archive" : "Buscar no arquivo";
|
||||||
|
const heroTitle = locale === "en"
|
||||||
|
? "Find a passage, a witness, a date."
|
||||||
|
: "Encontre uma passagem, uma testemunha, uma data.";
|
||||||
|
const heroLead = locale === "en"
|
||||||
|
? "Search across every declassified page in the bureau. Results link straight to the source — page, chunk, and the original bounding box around the passage."
|
||||||
|
: "Busque em todas as páginas desclassificadas do bureau. Cada resultado leva direto à fonte — página, trecho e o recorte original ao redor da passagem.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen p-6 md:p-10 max-w-5xl mx-auto">
|
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
||||||
<div className="flex items-start justify-between gap-4 mb-6">
|
<SiteHeader locale={locale} />
|
||||||
<Link href="/" className="font-mono text-xs text-[#7fdbff] hover:text-[#00ff9c]">
|
<main id="main" className="mx-auto max-w-5xl px-4 md:px-8 py-10 md:py-14">
|
||||||
← home
|
<header className="mb-8 md:mb-10">
|
||||||
</Link>
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-3">
|
||||||
<AuthBar />
|
{heroEyebrow}
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="font-display text-3xl md:text-5xl font-semibold leading-[1.05] tracking-tight text-[#e7ecf3] mb-4">
|
||||||
|
{heroTitle}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[16px] md:text-[17px] text-[#cbd2dd] leading-relaxed font-light max-w-2xl">
|
||||||
|
{heroLead}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<header className="mb-6">
|
<SearchPanel
|
||||||
<div className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase mb-2">
|
locale={locale}
|
||||||
hybrid search · BM25 + BGE-M3 dense + cross-encoder rerank
|
initialQ={sp.q ?? ""}
|
||||||
</div>
|
initialLang={lang}
|
||||||
<h1 className="font-mono text-2xl text-[#00ff9c] mb-1">▍ Busca semântica</h1>
|
initialType={sp.type ?? ""}
|
||||||
</header>
|
initialDocId={sp.doc_id ?? ""}
|
||||||
|
/>
|
||||||
<SearchPanel
|
</main>
|
||||||
initialQ={sp.q ?? ""}
|
</div>
|
||||||
initialLang={(sp.lang as "pt" | "en") ?? "pt"}
|
|
||||||
initialType={sp.type ?? ""}
|
|
||||||
initialDocId={sp.doc_id ?? ""}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* SearchPanel — full hybrid_search page with form controls and rich result cards.
|
* SearchPanel — full hybrid_search page with form controls and rich result cards.
|
||||||
*
|
*
|
||||||
* Syncs URL params so results are shareable. Click a result to open its chunk
|
* Magazine palette (gold + cream), localised copy, no developer jargon. The
|
||||||
* anchor in the V2 view.
|
* 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";
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Search as SearchIcon, SlidersHorizontal } from "lucide-react";
|
||||||
import { SearchAutocomplete } from "./search-autocomplete";
|
import { SearchAutocomplete } from "./search-autocomplete";
|
||||||
|
|
||||||
interface Hit {
|
interface Hit {
|
||||||
|
|
@ -23,12 +29,65 @@ interface Hit {
|
||||||
href: string;
|
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({
|
export function SearchPanel({
|
||||||
|
locale = "pt-br",
|
||||||
initialQ,
|
initialQ,
|
||||||
initialLang,
|
initialLang,
|
||||||
initialType,
|
initialType,
|
||||||
initialDocId,
|
initialDocId,
|
||||||
}: {
|
}: {
|
||||||
|
locale?: "pt-br" | "en";
|
||||||
initialQ: string;
|
initialQ: string;
|
||||||
initialLang: "pt" | "en";
|
initialLang: "pt" | "en";
|
||||||
initialType: string;
|
initialType: string;
|
||||||
|
|
@ -36,13 +95,14 @@ export function SearchPanel({
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
|
const t = COPY[locale];
|
||||||
const [q, setQ] = useState(initialQ);
|
const [q, setQ] = useState(initialQ);
|
||||||
const [lang, setLang] = useState<"pt" | "en">(initialLang);
|
|
||||||
const [type, setType] = useState(initialType);
|
const [type, setType] = useState(initialType);
|
||||||
const [docId, setDocId] = useState(initialDocId);
|
const [docId, setDocId] = useState(initialDocId);
|
||||||
const [hits, setHits] = useState<Hit[]>([]);
|
const [hits, setHits] = useState<Hit[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(Boolean(initialType || initialDocId));
|
||||||
|
|
||||||
// Initial fetch if URL had params
|
// Initial fetch if URL had params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -50,12 +110,12 @@ export function SearchPanel({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function doSearch(qStr: string, l: "pt" | "en", t: string, d: string) {
|
async function doSearch(qStr: string, l: "pt" | "en", typ: string, d: string) {
|
||||||
if (!qStr.trim()) return;
|
if (!qStr.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const sp = new URLSearchParams({ q: qStr, lang: l, top_k: "25" });
|
const sp = new URLSearchParams({ q: qStr, lang: l, top_k: "25" });
|
||||||
if (t) sp.set("type", t);
|
if (typ) sp.set("type", typ);
|
||||||
if (d) sp.set("doc_id", d);
|
if (d) sp.set("doc_id", d);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/search/hybrid?${sp}`);
|
const r = await fetch(`/api/search/hybrid?${sp}`);
|
||||||
|
|
@ -77,111 +137,105 @@ export function SearchPanel({
|
||||||
|
|
||||||
function submit(e: React.FormEvent) {
|
function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Sync URL (shareable)
|
|
||||||
const sp = new URLSearchParams(params.toString());
|
const sp = new URLSearchParams(params.toString());
|
||||||
sp.set("q", q);
|
sp.set("q", q);
|
||||||
sp.set("lang", lang);
|
sp.set("lang", initialLang);
|
||||||
if (type) sp.set("type", type);
|
if (type) sp.set("type", type);
|
||||||
else sp.delete("type");
|
else sp.delete("type");
|
||||||
if (docId) sp.set("doc_id", docId);
|
if (docId) sp.set("doc_id", docId);
|
||||||
else sp.delete("doc_id");
|
else sp.delete("doc_id");
|
||||||
router.replace(`/search?${sp}`);
|
router.replace(`/search?${sp}`);
|
||||||
doSearch(q, lang, type, docId);
|
doSearch(q, initialLang, type, docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PASSAGE_TYPES = [
|
||||||
|
"paragraph", "heading", "image", "stamp", "signature",
|
||||||
|
"table_marker", "address_block", "form_field",
|
||||||
|
"classification_marking", "redaction",
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<form
|
<form
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
className="space-y-3 mb-8 p-4 border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded"
|
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="relative">
|
||||||
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
<div className="flex items-stretch gap-2">
|
||||||
query
|
<div className="relative flex-1">
|
||||||
</label>
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#9aa6b8]">
|
||||||
<input
|
<SearchIcon size={16} aria-hidden="true" />
|
||||||
value={q}
|
</span>
|
||||||
onChange={(e) => setQ(e.target.value)}
|
<input
|
||||||
placeholder="ex. objetos esféricos avistados em Kansas, MJ-12, Roswell..."
|
value={q}
|
||||||
className="w-full bg-transparent border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-3 py-2 font-mono text-sm text-[#c8d4e6] outline-none"
|
onChange={(e) => setQ(e.target.value)}
|
||||||
autoFocus
|
placeholder={t.placeholder}
|
||||||
/>
|
aria-label={t.search}
|
||||||
<SearchAutocomplete query={q} onPick={() => setQ("")} />
|
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"
|
||||||
</div>
|
autoFocus
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
/>
|
||||||
<div>
|
<SearchAutocomplete query={q} onPick={() => setQ("")} />
|
||||||
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
|
||||||
idioma
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(["pt", "en"] as const).map((l) => (
|
|
||||||
<button
|
|
||||||
key={l}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLang(l)}
|
|
||||||
className={`px-3 py-1 font-mono text-xs rounded border ${
|
|
||||||
lang === l
|
|
||||||
? "border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.10)]"
|
|
||||||
: "border-[rgba(127,219,255,0.20)] text-[#8896aa]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{l}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
<div>
|
type="submit"
|
||||||
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
disabled={!q.trim() || loading}
|
||||||
chunk type
|
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"
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={type}
|
|
||||||
onChange={(e) => setType(e.target.value)}
|
|
||||||
className="bg-[#0a121e] border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-2 py-1 font-mono text-xs text-[#c8d4e6] outline-none"
|
|
||||||
>
|
>
|
||||||
<option value="">qualquer</option>
|
{loading ? t.searching : t.search}
|
||||||
{[
|
</button>
|
||||||
"paragraph",
|
|
||||||
"heading",
|
|
||||||
"image",
|
|
||||||
"stamp",
|
|
||||||
"signature",
|
|
||||||
"table_marker",
|
|
||||||
"address_block",
|
|
||||||
"form_field",
|
|
||||||
"classification_marking",
|
|
||||||
"redaction",
|
|
||||||
].map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-[180px]">
|
|
||||||
<label className="font-mono text-[10px] uppercase tracking-widest text-[#5a6678] block mb-1">
|
|
||||||
filtrar doc
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={docId}
|
|
||||||
onChange={(e) => setDocId(e.target.value)}
|
|
||||||
placeholder="opcional: doc-id exato"
|
|
||||||
className="w-full bg-transparent border border-[rgba(0,255,156,0.20)] focus:border-[#00ff9c] rounded px-2 py-1 font-mono text-xs text-[#c8d4e6] outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!q.trim() || loading}
|
onClick={() => setAdvancedOpen((v) => !v)}
|
||||||
className="px-4 py-1.5 font-mono text-xs uppercase tracking-widest border border-[#00ff9c] text-[#00ff9c] bg-[rgba(0,255,156,0.10)] hover:bg-[rgba(0,255,156,0.20)] rounded disabled:opacity-40 disabled:cursor-not-allowed"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? "buscando…" : "buscar"}
|
<SlidersHorizontal size={12} aria-hidden="true" />
|
||||||
|
<span>{t.advanced}</span>
|
||||||
|
<span aria-hidden="true">{advancedOpen ? "−" : "+"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
{error && (
|
{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-xs text-[#ff6b6b]">
|
<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]">
|
||||||
retrieval indisponível: {error}
|
{t.error_prefix}{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -191,11 +245,12 @@ export function SearchPanel({
|
||||||
? `/api/crop?doc=${encodeURIComponent(h.doc_id)}&page=${h.page}` +
|
? `/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`
|
`&x=${h.bbox.x}&y=${h.bbox.y}&w=${h.bbox.w}&h=${h.bbox.h}&w_px=320`
|
||||||
: null;
|
: null;
|
||||||
|
const typeLabel = t.types_map[h.type] ?? h.type;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={`${h.doc_id}-${h.chunk_id}`}
|
key={`${h.doc_id}-${h.chunk_id}`}
|
||||||
href={h.href}
|
href={h.href}
|
||||||
className="block p-3 bg-[#0a121e] border border-[rgba(127,219,255,0.20)] hover:border-[#00ff9c] rounded transition flex gap-4"
|
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 && (
|
{cropUrl && (
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -203,21 +258,23 @@ export function SearchPanel({
|
||||||
alt=""
|
alt=""
|
||||||
width={160}
|
width={160}
|
||||||
height={100}
|
height={100}
|
||||||
className="block w-40 h-24 object-cover bg-black rounded flex-shrink-0"
|
className="block w-40 h-24 object-cover bg-black rounded-md flex-shrink-0"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-mono mb-1">
|
<div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wider mb-1.5">
|
||||||
<span className="text-[#00ff9c] font-bold">{h.chunk_id}</span>
|
<span className="text-[#e0c080]">p{h.page}</span>
|
||||||
<span className="text-[#5a6678]">p{h.page}</span>
|
<span className="text-[#5a6678]" aria-hidden="true">·</span>
|
||||||
<span className="text-[#5a6678]">{h.type}</span>
|
<span className="text-[#9aa6b8]">{typeLabel}</span>
|
||||||
{h.classification && (
|
{h.classification && (
|
||||||
<span className="text-[#ff6b6b]">{h.classification}</span>
|
<>
|
||||||
|
<span className="text-[#5a6678]" aria-hidden="true">·</span>
|
||||||
|
<span className="text-[#ff9696]">{h.classification}</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-[#8896aa]">{h.score.toFixed(3)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#c8d4e6] text-sm line-clamp-3 mb-1">{h.snippet}</p>
|
<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 className="text-[10px] font-mono text-[#5a6678] truncate">{h.doc_id}</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -226,8 +283,8 @@ export function SearchPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!loading && !error && initialQ && hits.length === 0 && (
|
{!loading && !error && initialQ && hits.length === 0 && (
|
||||||
<div className="text-center text-[#5a6678] font-mono text-sm py-12">
|
<div className="text-center text-[#5a6678] font-mono text-[13px] py-12">
|
||||||
nenhum resultado
|
{t.empty}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue