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>
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
"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<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: "A–Z",
|
||
} 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>
|
||
);
|
||
}
|