diff --git a/web/app/search/page.tsx b/web/app/search/page.tsx index e23d743..f284ee2 100644 --- a/web/app/search/page.tsx +++ b/web/app/search/page.tsx @@ -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 - * cards (bbox crop, classification badge, full snippet). Bookmarkable. + * Magazine-style page: same global SiteHeader as the rest of the site, an + * 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 { AuthBar } from "@/components/auth-bar"; +import type { Metadata } from "next"; 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"; +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top"; + +export async function generateMetadata(): Promise { + 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({ searchParams, }: { searchParams: Promise<{ q?: string; lang?: string; type?: string; doc_id?: string }>; }) { 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 ( -
-
- - ← home - - -
+
+ +
+
+
+ {heroEyebrow} +
+

+ {heroTitle} +

+

+ {heroLead} +

+
-
-
- hybrid search · BM25 + BGE-M3 dense + cross-encoder rerank -
-

▍ Busca semântica

-
- - -
+ +
+ ); } diff --git a/web/components/search-panel.tsx b/web/components/search-panel.tsx index 653bfb9..674d73a 100644 --- a/web/components/search-panel.tsx +++ b/web/components/search-panel.tsx @@ -1,14 +1,20 @@ /** * 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 - * anchor in the V2 view. + * 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 { @@ -23,12 +29,65 @@ interface Hit { 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; @@ -36,13 +95,14 @@ export function SearchPanel({ }) { const router = useRouter(); const params = useSearchParams(); + const t = COPY[locale]; const [q, setQ] = useState(initialQ); - const [lang, setLang] = useState<"pt" | "en">(initialLang); 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(() => { @@ -50,12 +110,12 @@ export function SearchPanel({ // 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; setLoading(true); setError(null); 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); try { const r = await fetch(`/api/search/hybrid?${sp}`); @@ -77,111 +137,105 @@ export function SearchPanel({ function submit(e: React.FormEvent) { e.preventDefault(); - // Sync URL (shareable) const sp = new URLSearchParams(params.toString()); sp.set("q", q); - sp.set("lang", lang); + 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, 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 (
- - setQ(e.target.value)} - placeholder="ex. objetos esféricos avistados em Kansas, MJ-12, Roswell..." - 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" - autoFocus - /> - setQ("")} /> -
-
-
- -
- {(["pt", "en"] as const).map((l) => ( - - ))} +
+
+ + + 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("")} />
-
-
- - -
-
- - 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" - /> + {loading ? t.searching : t.search} +
+
+ +
+ + {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 && ( -
- retrieval indisponível: {error} +
+ {t.error_prefix}{error}
)} @@ -191,11 +245,12 @@ export function SearchPanel({ ? `/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 && ( )}
-
- {h.chunk_id} - p{h.page} - {h.type} +
+ p{h.page} + + {typeLabel} {h.classification && ( - {h.classification} + <> + + {h.classification} + )} - {h.score.toFixed(3)}
-

{h.snippet}

+

{h.snippet}

{h.doc_id}
@@ -226,8 +283,8 @@ export function SearchPanel({
{!loading && !error && initialQ && hits.length === 0 && ( -
- nenhum resultado +
+ {t.empty}
)}