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>
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
/**
|
|
* CaseLibrary — homepage case-file strip.
|
|
*
|
|
* The /bureau page uses a richer browser with filters; the home only needs
|
|
* a quick magazine-style preview (one hero + a few cards) above the fold.
|
|
* Both pull from the shared lib/cases.ts loader.
|
|
*/
|
|
import Link from "next/link";
|
|
import { loadCases, type CaseFile } from "@/lib/cases";
|
|
|
|
export async function CaseLibrary({
|
|
locale,
|
|
limit,
|
|
layout = "hero+grid",
|
|
}: {
|
|
locale: "pt-br" | "en";
|
|
limit?: number;
|
|
/** "hero+grid" = first case big + rest small (homepage); "grid" = all equal */
|
|
layout?: "hero+grid" | "grid";
|
|
}) {
|
|
const allCases = await loadCases(locale);
|
|
const cases = typeof limit === "number" ? allCases.slice(0, limit) : allCases;
|
|
if (cases.length === 0) {
|
|
return (
|
|
<section className="my-8 rounded-lg border border-dashed border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-6 text-center">
|
|
<p className="text-[12px] font-mono text-[#9aa6b8]">
|
|
{locale === "pt-br"
|
|
? "Nenhum caso narrado ainda. As primeiras histórias chegam em breve."
|
|
: "No case files yet. The first stories arrive soon."}
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const [hero, ...rest] = cases;
|
|
|
|
return (
|
|
<section className="my-8">
|
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#5a6678] mb-3">
|
|
{locale === "pt-br" ? "// Casos em destaque" : "// Featured case files"}
|
|
</div>
|
|
|
|
{layout === "hero+grid" ? (
|
|
<>
|
|
<CaseHero c={hero} locale={locale} />
|
|
{rest.length > 0 && (
|
|
<div className="grid md:grid-cols-2 gap-4 mt-4">
|
|
{rest.map((c) => <CaseCard key={c.slug} c={c} locale={locale} />)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{cases.map((c) => <CaseCard key={c.slug} c={c} locale={locale} />)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CaseHero({ c, locale }: { c: CaseFile; locale: "pt-br" | "en" }) {
|
|
const title = locale === "pt-br" ? (c.topic_pt_br ?? c.topic) : c.topic;
|
|
return (
|
|
<Link
|
|
href={`/c/${c.slug}`}
|
|
className="block rounded-xl border border-[rgba(224,192,128,0.30)] bg-gradient-to-br from-[rgba(224,192,128,0.07)] via-[#0d1220] to-transparent p-6 md:p-8 hover:border-[#e0c080] transition-colors group"
|
|
>
|
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-2">
|
|
{locale === "pt-br" ? "Arquivo desclassificado" : "Declassified case file"}
|
|
</div>
|
|
<h2 className="font-mono text-2xl md:text-3xl text-[#e7ecf3] leading-tight mb-3 group-hover:text-[#e0c080] transition-colors">
|
|
{title}
|
|
</h2>
|
|
{c.opening && (
|
|
<p className="text-[15px] text-[#cbd2dd] leading-relaxed line-clamp-5">
|
|
{c.opening}
|
|
</p>
|
|
)}
|
|
<div className="mt-4 text-[12px] font-mono text-[#e0c080]">
|
|
{locale === "pt-br" ? "ler o caso completo →" : "read the full case file →"}
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function CaseCard({ c, locale }: { c: CaseFile; locale: "pt-br" | "en" }) {
|
|
const title = locale === "pt-br" ? (c.topic_pt_br ?? c.topic) : c.topic;
|
|
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"
|
|
>
|
|
<h3 className="font-mono text-lg text-[#e7ecf3] leading-tight mb-2 group-hover:text-[#e0c080] transition-colors">
|
|
{title}
|
|
</h3>
|
|
{c.opening && (
|
|
<p className="text-[13px] text-[#9aa6b8] leading-relaxed line-clamp-3">
|
|
{c.opening}
|
|
</p>
|
|
)}
|
|
<div className="mt-3 text-[11px] font-mono text-[#5a6678]">
|
|
/c/{c.slug}
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|