disclosure-bureau/web/components/case-library-browser.tsx
Luiz Gustavo 4fb3e12e15
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 35s
CI / Scripts — Python smoke (push) Failing after 6s
CI / Web — npm audit (push) Failing after 39s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 6s
ux(bureau): search + agency/decade filters + sort, URL-synced
The audit flagged /bureau as the hardest page to navigate: 80+ case cards
in a flat grid with no way to narrow them down. Now the reader gets a
filterable browser.

- New lib/cases.ts (server) — shared loader for the case library; pulls
  cases off disk, derives the originating agency from the slug prefix
  (DoW, FBI, NASA, CIA, DoE, ODNI, DoD, Other) and the incident year via
  a tight 19xx/20xx regex that requires non-digit boundaries so embedded
  ID numbers don't get mistaken for years (e.g. doc-2070-... no longer
  pretends to be a 2070 incident).
- New CaseLibraryBrowser (client) — search input (matches title + opening
  in either language), agency chips with live counts, decade chips, sort
  dropdown (Most recent / Year ↑ / Year ↓ / A–Z), result count, and a
  clear-filters affordance. All four state pieces sync to the URL so a
  filtered view is bookmarkable.
- /bureau page rewritten: magazine hero ("89 declassified cases,
  narrated.") + dynamic case count + lead, then the browser.
- Cards in the browser carry an eyebrow ("YEAR · AGENCY") so the reader
  sees provenance at a glance.
- CaseLibrary (homepage) refactored to consume the shared loader; behaviour
  on / unchanged.

Verified live: agency=FBI filter narrows to 23 cases; sort=year-desc places
modern cases first; chip counts reflect filtered population.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 01:21:39 -03:00

303 lines
10 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.

"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 / AZ.
*
* 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: "AZ",
} as Record<SortKey, string>,
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: "AZ",
} as Record<SortKey, string>,
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<Agency | null>((params.get("agency") as Agency) ?? null);
const [decade, setDecade] = useState<string | null>(params.get("decade"));
const [sort, setSort] = useState<SortKey>((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<Agency, number>();
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<string, number>();
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 (
<div>
{/* Search + sort row */}
<div className="flex flex-col md:flex-row md:items-center gap-3 mb-4">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#9aa6b8]">
<Search size={16} aria-hidden="true" />
</span>
<input
value={q}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setQ("")}
aria-label={t.clear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#9aa6b8] hover:text-[#e0c080]"
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
<label className="inline-flex items-center gap-2 text-[12px] font-mono text-[#9aa6b8]">
<span className="uppercase tracking-widest text-[10px]">{t.sort_label}</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="bg-[#0a0e1a] border border-[rgba(224,192,128,0.25)] focus:border-[#e0c080] rounded px-2.5 py-1.5 font-mono text-[12px] text-[#e7ecf3] outline-none"
>
{(Object.keys(t.sorts) as SortKey[]).map((k) => (
<option key={k} value={k}>{t.sorts[k]}</option>
))}
</select>
</label>
</div>
{/* Agency chips */}
<ChipRow
label="agency"
items={[
{ key: null, label: t.all, count: cases.length },
...AGENCY_ORDER
.filter((a) => (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 */}
<ChipRow
label="decade"
items={[
{ key: null, label: t.all, count: cases.length },
...decadeKeys.map((d) => ({
key: d,
label: d === "—" ? t.no_decade : d,
count: decadeCounts.get(d) ?? 0,
})),
]}
active={decade}
onPick={setDecade}
/>
{/* Result count + reset */}
<div className="mt-4 mb-3 flex items-center justify-between text-[11px] font-mono text-[#9aa6b8]">
<span>{t.showing(filtered.length, cases.length)}</span>
{hasActiveFilter && (
<button
type="button"
onClick={() => { setQ(""); setAgency(null); setDecade(null); }}
className="text-[#e0c080] hover:underline"
>
{t.reset}
</button>
)}
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div className="text-center text-[#5a6678] font-mono text-[13px] py-12 border border-dashed border-[rgba(224,192,128,0.18)] rounded-lg">
{t.no_results}
</div>
) : (
<div className="grid md:grid-cols-2 gap-4">
{filtered.map((c) => <CaseCard key={c.slug} c={c} locale={locale} />)}
</div>
)}
</div>
);
}
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 (
<div className="flex flex-wrap items-center gap-1.5 mb-2" role="group" aria-label={label}>
{items.map((it) => {
const isActive = active === it.key;
return (
<button
key={String(it.key)}
type="button"
onClick={() => onPick(it.key)}
aria-pressed={isActive}
className={
isActive
? "inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[#e0c080] bg-[rgba(224,192,128,0.16)] text-[#e0c080] text-[12px] font-mono"
: "inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[rgba(224,192,128,0.20)] text-[#cbd2dd] hover:text-[#e0c080] hover:border-[rgba(224,192,128,0.45)] text-[12px] font-mono transition-colors"
}
>
<span>{it.label}</span>
<span className="text-[10px] text-[#9aa6b8]">{it.count}</span>
</button>
);
})}
</div>
);
}
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 (
<Link
href={`/c/${c.slug}`}
className="block rounded-lg border border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-4 hover:border-[#e0c080] transition-colors group"
>
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#9aa6b8] mb-1.5">
{meta.join(" · ")}
</div>
<h3 className="font-display text-[19px] text-[#e7ecf3] leading-snug mb-2 group-hover:text-[#e0c080] transition-colors">
{title}
</h3>
{c.opening && (
<p className="text-[13px] text-[#cbd2dd] leading-relaxed line-clamp-3">
{c.opening}
</p>
)}
</Link>
);
}