disclosure-bureau/web/components/case-library.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

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>
);
}