"use client"; /** * CaseLibraryBrowser — filterable, sortable, searchable view of every * narrated case file. Powers /bureau. * * The previous /bureau dumped 80+ cards in a flat grid; the audit flagged * that as the hardest page to navigate. Now the reader gets: * * - A search input that matches title and opening (substring, both * languages). * - Agency chips with live counts (DoW, FBI, NASA, …, Other). * - Decade chips (1940s … 2020s). * - Sort dropdown: most recent / oldest first / newest first / A–Z. * * State is URL-synced so a filtered view can be linked or bookmarked. * Pure client filter — all 84-ish cases are passed in once and filtered in * memory. Cheap. */ import Link from "next/link"; import { useMemo, useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Search, X } from "lucide-react"; import type { Agency, CaseFile } from "@/lib/cases"; type SortKey = "recent" | "year-desc" | "year-asc" | "az"; const COPY = { "pt-br": { search_placeholder: "Buscar por título ou texto…", clear: "Limpar busca", sort_label: "Ordenar", sorts: { recent: "Mais recentes", "year-desc": "Ano: ↓ recente", "year-asc": "Ano: ↑ antigo", az: "A–Z", } as Record, all: "Todos", no_decade: "sem data", showing: (shown: number, total: number) => `Mostrando ${shown} de ${total} casos`, no_results: "Nenhum caso bate com esses filtros.", reset: "Limpar filtros", open_case: "ler o caso completo →", }, en: { search_placeholder: "Search by title or text…", clear: "Clear search", sort_label: "Sort", sorts: { recent: "Most recent", "year-desc": "Year: newest first", "year-asc": "Year: oldest first", az: "A–Z", } as Record, all: "All", no_decade: "undated", showing: (shown: number, total: number) => `Showing ${shown} of ${total} cases`, no_results: "No case matches these filters.", reset: "Clear filters", open_case: "read the full case file →", }, } as const; const AGENCY_ORDER: Agency[] = ["DoW", "FBI", "NASA", "CIA", "DoE", "ODNI", "DoD", "Other"]; export function CaseLibraryBrowser({ locale, cases, }: { locale: "pt-br" | "en"; cases: CaseFile[] }) { const t = COPY[locale]; const router = useRouter(); const params = useSearchParams(); // Initial state from URL params. const [q, setQ] = useState(params.get("q") ?? ""); const [agency, setAgency] = useState((params.get("agency") as Agency) ?? null); const [decade, setDecade] = useState(params.get("decade")); const [sort, setSort] = useState((params.get("sort") as SortKey) ?? "recent"); // Push state to URL (replaceState) so the browser bar stays canonical. useEffect(() => { const sp = new URLSearchParams(); if (q.trim()) sp.set("q", q.trim()); if (agency) sp.set("agency", agency); if (decade) sp.set("decade", decade); if (sort && sort !== "recent") sp.set("sort", sort); const qs = sp.toString(); router.replace(qs ? `/bureau?${qs}` : "/bureau", { scroll: false }); }, [q, agency, decade, sort, router]); const agencyCounts = useMemo(() => { const m = new Map(); for (const c of cases) m.set(c.agency, (m.get(c.agency) ?? 0) + 1); return m; }, [cases]); const decadeCounts = useMemo(() => { const m = new Map(); for (const c of cases) { const d = c.decade ?? "—"; m.set(d, (m.get(d) ?? 0) + 1); } return m; }, [cases]); const decadeKeys = useMemo(() => { const ks = [...decadeCounts.keys()]; // Sort 1940s, 1950s, … 2020s, "—" last. return ks.sort((a, b) => { if (a === "—") return 1; if (b === "—") return -1; return parseInt(a, 10) - parseInt(b, 10); }); }, [decadeCounts]); const filtered = useMemo(() => { const qq = q.trim().toLowerCase(); let r = cases.filter((c) => { if (agency && c.agency !== agency) return false; if (decade) { const d = c.decade ?? "—"; if (d !== decade) return false; } if (qq) { const inEn = c.topic.toLowerCase().includes(qq); const inPt = (c.topic_pt_br ?? "").toLowerCase().includes(qq); const inOp = c.opening.toLowerCase().includes(qq); if (!(inEn || inPt || inOp)) return false; } return true; }); if (sort === "year-desc") { r = [...r].sort((a, b) => (b.year ?? -Infinity) - (a.year ?? -Infinity)); } else if (sort === "year-asc") { r = [...r].sort((a, b) => (a.year ?? Infinity) - (b.year ?? Infinity)); } else if (sort === "az") { const key = (c: CaseFile) => (locale === "pt-br" ? (c.topic_pt_br ?? c.topic) : c.topic).toLowerCase(); r = [...r].sort((a, b) => key(a).localeCompare(key(b))); } // recent: cases are pre-sorted by mtime return r; }, [cases, q, agency, decade, sort, locale]); const hasActiveFilter = q.trim() !== "" || agency !== null || decade !== null; return (
{/* Search + sort row */}
setQ(e.target.value)} placeholder={t.search_placeholder} aria-label={t.search_placeholder} className="w-full bg-[#0a0e1a] border border-[rgba(224,192,128,0.25)] focus:border-[#e0c080] rounded-full pl-10 pr-10 py-2.5 font-display text-[15px] text-[#e7ecf3] placeholder:text-[#5a6678] outline-none transition-colors" /> {q && ( )}
{/* Agency chips */} (agencyCounts.get(a) ?? 0) > 0) .map((a) => ({ key: a as string | null, label: a, count: agencyCounts.get(a) ?? 0 })), ]} active={agency} onPick={(k) => setAgency(k as Agency | null)} /> {/* Decade chips */} ({ key: d, label: d === "—" ? t.no_decade : d, count: decadeCounts.get(d) ?? 0, })), ]} active={decade} onPick={setDecade} /> {/* Result count + reset */}
{t.showing(filtered.length, cases.length)} {hasActiveFilter && ( )}
{/* Grid */} {filtered.length === 0 ? (
{t.no_results}
) : (
{filtered.map((c) => )}
)}
); } function ChipRow({ label, items, active, onPick, }: { label: string; items: { key: string | null; label: string; count: number }[]; active: string | null; onPick: (k: string | null) => void; }) { return (
{items.map((it) => { const isActive = active === it.key; return ( ); })}
); } function CaseCard({ c, locale }: { c: CaseFile; locale: "pt-br" | "en" }) { const title = locale === "pt-br" ? (c.topic_pt_br ?? c.topic) : c.topic; const meta: string[] = []; if (c.year !== null) meta.push(String(c.year)); meta.push(c.agency); return (
{meta.join(" · ")}

{title}

{c.opening && (

{c.opening}

)} ); }