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>
This commit is contained in:
parent
067ce7bee6
commit
4fb3e12e15
4 changed files with 489 additions and 122 deletions
|
|
@ -1,36 +1,44 @@
|
||||||
/**
|
/**
|
||||||
* /bureau — Case file library.
|
* /bureau — Case file library.
|
||||||
*
|
*
|
||||||
* Reader-facing list of every assembled narrative. No detective surfacing,
|
* Magazine hero + filterable browser. The previous flat 80-card grid had no
|
||||||
* no artefact dumps. Just stories with hooks.
|
* way to navigate; the new browser carries search, agency chips, decade
|
||||||
|
* chips, and sort, all URL-synced.
|
||||||
*/
|
*/
|
||||||
import { BureauNav } from "@/components/bureau-nav";
|
import { BureauNav } from "@/components/bureau-nav";
|
||||||
import { CaseLibrary } from "@/components/case-library";
|
import { CaseLibraryBrowser } from "@/components/case-library-browser";
|
||||||
import { getLocale } from "@/components/locale-toggle";
|
import { getLocale } from "@/components/locale-toggle";
|
||||||
|
import { loadCases } from "@/lib/cases";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function BureauPage() {
|
export default async function BureauPage() {
|
||||||
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
||||||
|
const cases = await loadCases(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
||||||
<BureauNav locale={locale} crumbs={[{ label: locale === "en" ? "case files" : "casos" }]} />
|
<BureauNav locale={locale} crumbs={[{ label: locale === "en" ? "case files" : "casos" }]} />
|
||||||
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
|
<main id="main" className="mx-auto max-w-5xl px-4 md:px-8 py-10 md:py-14">
|
||||||
<header className="mb-6 border-b border-[rgba(224,192,128,0.32)] pb-4">
|
<header className="mb-8 md:mb-10">
|
||||||
<h1 className="font-mono text-3xl text-[#e0c080]">
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-3">
|
||||||
{locale === "en" ? "▍ The Case Files" : "▍ Os Arquivos do Caso"}
|
{locale === "en" ? "The case files" : "Os arquivos do caso"}
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-[#8896aa] text-sm mt-1">
|
<h1 className="font-display text-3xl md:text-5xl font-semibold leading-[1.05] tracking-tight text-[#e7ecf3] mb-4">
|
||||||
{locale === "en"
|
{locale === "en"
|
||||||
? "Narratives assembled from the declassified record. Each case is a story — written from primary documents, with citations linked to source pages."
|
? `${cases.length} declassified cases, narrated.`
|
||||||
: "Narrativas montadas a partir do registro desclassificado. Cada caso é uma história — escrita a partir de documentos primários, com citações vinculadas às páginas-fonte."}
|
: `${cases.length} casos desclassificados, narrados.`}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[16px] md:text-[17px] text-[#cbd2dd] leading-relaxed font-light max-w-2xl">
|
||||||
|
{locale === "en"
|
||||||
|
? "Stories assembled from primary documents — memos, debriefs, intelligence reports — with every claim linked back to the source page."
|
||||||
|
: "Histórias montadas a partir de documentos primários — memorandos, debriefings, relatórios de inteligência — com cada afirmação vinculada à página-fonte."}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<CaseLibrary locale={locale} layout="grid" />
|
<CaseLibraryBrowser locale={locale} cases={cases} />
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
303
web/components/case-library-browser.tsx
Normal file
303
web/components/case-library-browser.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,115 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* CaseLibrary — public-facing case file library.
|
* CaseLibrary — homepage case-file strip.
|
||||||
*
|
*
|
||||||
* The Disclosure Bureau's outward face. Reads /data/ufo/case/reports/*.md,
|
* The /bureau page uses a richer browser with filters; the home only needs
|
||||||
* parses the YAML frontmatter for the case topic, extracts the opening
|
* a quick magazine-style preview (one hero + a few cards) above the fold.
|
||||||
* paragraph as a preview hook, and renders a magazine-style grid. No
|
* Both pull from the shared lib/cases.ts loader.
|
||||||
* mention of "detectives" anywhere — the AI pipeline is plumbing, not the
|
|
||||||
* brand.
|
|
||||||
*
|
|
||||||
* Server component. Single fs.readdir + N small readFile calls (one per
|
|
||||||
* report). Cheap; reports are O(10).
|
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { readdir, readFile, stat } from "node:fs/promises";
|
import { loadCases, type CaseFile } from "@/lib/cases";
|
||||||
import path from "node:path";
|
|
||||||
import { pickLocaleBody } from "@/lib/bilingual";
|
|
||||||
|
|
||||||
interface CaseFile {
|
|
||||||
slug: string;
|
|
||||||
topic: string;
|
|
||||||
topic_pt_br: string | null;
|
|
||||||
/** Opening paragraph of the body, trimmed and language-picked. */
|
|
||||||
opening: string;
|
|
||||||
mtimeMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case";
|
|
||||||
|
|
||||||
function parseFrontmatter(md: string): { fm: Record<string, string>; body: string } {
|
|
||||||
const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/);
|
|
||||||
if (!m) return { fm: {}, body: md };
|
|
||||||
const fm: Record<string, string> = {};
|
|
||||||
for (const line of m[1].split("\n")) {
|
|
||||||
const kv = line.match(/^([a-z_]+):\s*(.+)$/);
|
|
||||||
if (!kv) continue;
|
|
||||||
let v = kv[2].trim();
|
|
||||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
||||||
fm[kv[1]] = v;
|
|
||||||
}
|
|
||||||
return { fm, body: m[2] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull the first prose paragraph that looks like a narrative opening.
|
|
||||||
* Skips H1, H2, blockquotes, table separators, and the "(EN)" / "(PT-BR)"
|
|
||||||
* sub-headers. Prefers PT-BR when locale is pt-br.
|
|
||||||
*/
|
|
||||||
function pickOpening(body: string, locale: "pt-br" | "en"): string {
|
|
||||||
const lines = body.split("\n");
|
|
||||||
const blocks: string[] = [];
|
|
||||||
let cur: string[] = [];
|
|
||||||
for (const raw of lines) {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (line.length === 0) {
|
|
||||||
if (cur.length > 0) { blocks.push(cur.join(" ")); cur = []; }
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.startsWith("#") || line.startsWith(">") || line.startsWith("|")
|
|
||||||
|| line.startsWith("---") || line.startsWith("```")) {
|
|
||||||
if (cur.length > 0) { blocks.push(cur.join(" ")); cur = []; }
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cur.push(line);
|
|
||||||
}
|
|
||||||
if (cur.length > 0) blocks.push(cur.join(" "));
|
|
||||||
|
|
||||||
// For PT-BR locale, prefer a block right after a "(PT-BR)" heading.
|
|
||||||
// For EN locale, prefer the first prose block (the EN sub-section usually
|
|
||||||
// appears first under each act).
|
|
||||||
if (locale === "pt-br") {
|
|
||||||
const ptIdx = body.indexOf("(PT-BR)");
|
|
||||||
if (ptIdx >= 0) {
|
|
||||||
const after = body.slice(ptIdx);
|
|
||||||
const m = after.match(/\n\n([^\n#>|`-][^\n]+(?:\n[^\n#>|`-][^\n]+)*)/);
|
|
||||||
if (m) return m[1].replace(/\s+/g, " ").trim().slice(0, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (blocks[0] ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCases(locale: "pt-br" | "en"): Promise<CaseFile[]> {
|
|
||||||
const dir = path.join(CASE_ROOT, "reports");
|
|
||||||
let files: string[];
|
|
||||||
try {
|
|
||||||
files = await readdir(dir);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const items: CaseFile[] = [];
|
|
||||||
for (const f of files) {
|
|
||||||
if (!f.endsWith(".md")) continue;
|
|
||||||
try {
|
|
||||||
const full = path.join(dir, f);
|
|
||||||
const md = await readFile(full, "utf-8");
|
|
||||||
const st = await stat(full);
|
|
||||||
const { fm, body } = parseFrontmatter(md);
|
|
||||||
// Prefer the narrator's body H1 (magazine title) over the generic
|
|
||||||
// auto-derived frontmatter topic. Two H1s: EN then PT-BR.
|
|
||||||
const h1s = [...body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
||||||
const enTitle = h1s[0] ?? fm.topic ?? f;
|
|
||||||
const ptTitle = h1s[1] ?? h1s[0] ?? fm.topic_pt_br ?? fm.topic ?? f;
|
|
||||||
items.push({
|
|
||||||
slug: f.replace(/\.md$/, ""),
|
|
||||||
topic: enTitle,
|
|
||||||
topic_pt_br: ptTitle,
|
|
||||||
opening: pickOpening(pickLocaleBody(body, locale), locale),
|
|
||||||
mtimeMs: st.mtimeMs,
|
|
||||||
});
|
|
||||||
} catch { /* skip broken file */ }
|
|
||||||
}
|
|
||||||
return items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function CaseLibrary({
|
export async function CaseLibrary({
|
||||||
locale,
|
locale,
|
||||||
|
|
@ -118,7 +15,7 @@ export async function CaseLibrary({
|
||||||
}: {
|
}: {
|
||||||
locale: "pt-br" | "en";
|
locale: "pt-br" | "en";
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** "hero+grid" = first case big + rest small (homepage); "grid" = all equal (/bureau) */
|
/** "hero+grid" = first case big + rest small (homepage); "grid" = all equal */
|
||||||
layout?: "hero+grid" | "grid";
|
layout?: "hero+grid" | "grid";
|
||||||
}) {
|
}) {
|
||||||
const allCases = await loadCases(locale);
|
const allCases = await loadCases(locale);
|
||||||
|
|
|
||||||
159
web/lib/cases.ts
Normal file
159
web/lib/cases.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/**
|
||||||
|
* cases.ts — server-only loader for the case-file library.
|
||||||
|
*
|
||||||
|
* Reads /data/ufo/case/reports/*.md, parses frontmatter, derives the
|
||||||
|
* magazine title from the body H1, picks the locale-preferred opening
|
||||||
|
* paragraph, and infers two filter dimensions from the slug:
|
||||||
|
*
|
||||||
|
* - agency: which Department / Bureau owns the records (DoW, FBI, NASA,
|
||||||
|
* CIA, DoE, ODNI, DoD, Other). Inferred from the slug prefix.
|
||||||
|
* - year / decade: the incident year mentioned in the slug, when there is
|
||||||
|
* one (most slugs include it).
|
||||||
|
*
|
||||||
|
* The home CaseLibrary and the /bureau browser both consume this. Keeping
|
||||||
|
* it server-only means filesystem access stays out of the client bundle.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { readdir, readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pickLocaleBody } from "@/lib/bilingual";
|
||||||
|
|
||||||
|
export interface CaseFile {
|
||||||
|
slug: string;
|
||||||
|
topic: string;
|
||||||
|
topic_pt_br: string | null;
|
||||||
|
/** Opening paragraph of the body, trimmed and language-picked. */
|
||||||
|
opening: string;
|
||||||
|
/** Last modification timestamp, used as default sort. */
|
||||||
|
mtimeMs: number;
|
||||||
|
/** Incident year mentioned in the slug, or null. */
|
||||||
|
year: number | null;
|
||||||
|
/** Decade label like "1940s", "2020s", derived from `year`. */
|
||||||
|
decade: string | null;
|
||||||
|
/** Originating bureau / agency. */
|
||||||
|
agency: Agency;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Agency =
|
||||||
|
| "DoW" // Department of War (recent UAP releases)
|
||||||
|
| "FBI"
|
||||||
|
| "NASA"
|
||||||
|
| "CIA"
|
||||||
|
| "DoE"
|
||||||
|
| "ODNI"
|
||||||
|
| "DoD" // generic Department of Defense legacy
|
||||||
|
| "Other"; // named historic cases (Kenneth Arnold, Mantell, green fireballs, …)
|
||||||
|
|
||||||
|
const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case";
|
||||||
|
|
||||||
|
function parseFrontmatter(md: string): { fm: Record<string, string>; body: string } {
|
||||||
|
const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/);
|
||||||
|
if (!m) return { fm: {}, body: md };
|
||||||
|
const fm: Record<string, string> = {};
|
||||||
|
for (const line of m[1].split("\n")) {
|
||||||
|
const kv = line.match(/^([a-z_]+):\s*(.+)$/);
|
||||||
|
if (!kv) continue;
|
||||||
|
let v = kv[2].trim();
|
||||||
|
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||||
|
fm[kv[1]] = v;
|
||||||
|
}
|
||||||
|
return { fm, body: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOpening(body: string, locale: "pt-br" | "en"): string {
|
||||||
|
const lines = body.split("\n");
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let cur: string[] = [];
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.length === 0) {
|
||||||
|
if (cur.length > 0) { blocks.push(cur.join(" ")); cur = []; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("#") || line.startsWith(">") || line.startsWith("|")
|
||||||
|
|| line.startsWith("---") || line.startsWith("```")) {
|
||||||
|
if (cur.length > 0) { blocks.push(cur.join(" ")); cur = []; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur.push(line);
|
||||||
|
}
|
||||||
|
if (cur.length > 0) blocks.push(cur.join(" "));
|
||||||
|
|
||||||
|
if (locale === "pt-br") {
|
||||||
|
const ptIdx = body.indexOf("(PT-BR)");
|
||||||
|
if (ptIdx >= 0) {
|
||||||
|
const after = body.slice(ptIdx);
|
||||||
|
const m = after.match(/\n\n([^\n#>|`-][^\n]+(?:\n[^\n#>|`-][^\n]+)*)/);
|
||||||
|
if (m) return m[1].replace(/\s+/g, " ").trim().slice(0, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (blocks[0] ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a slug to its originating agency by prefix / known fragments. */
|
||||||
|
function inferAgency(slug: string): Agency {
|
||||||
|
if (slug.startsWith("dow-uap-")) return "DoW";
|
||||||
|
if (slug.startsWith("fbi-")) return "FBI";
|
||||||
|
if (slug.startsWith("nasa-")) return "NASA";
|
||||||
|
if (slug.startsWith("cia-")) return "CIA";
|
||||||
|
if (slug.startsWith("doe-")) return "DoE";
|
||||||
|
if (slug.startsWith("odni-")) return "ODNI";
|
||||||
|
// doc-65-hs1-… is the Hoover/FBI bulk dossier; ship it under FBI for the
|
||||||
|
// reader even though the slug doesn't say so.
|
||||||
|
if (slug.startsWith("doc-65-")) return "FBI";
|
||||||
|
if (slug.startsWith("doc-")) return "DoD";
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a 19xx / 20xx that looks like a standalone year token from the slug.
|
||||||
|
* Requires a non-digit boundary on each side, capping the search at the
|
||||||
|
* current year + 1 so embedded ID numbers like "doc-2070-…" don't get
|
||||||
|
* mistaken for a 2070 incident. */
|
||||||
|
function inferYear(slug: string): number | null {
|
||||||
|
const max = new Date().getFullYear() + 1;
|
||||||
|
const m = slug.match(/(?:^|[^0-9])(19\d{2}|20\d{2})(?:[^0-9]|$)/);
|
||||||
|
if (!m) return null;
|
||||||
|
const y = parseInt(m[1], 10);
|
||||||
|
return y <= max ? y : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decadeOf(year: number | null): string | null {
|
||||||
|
if (year === null) return null;
|
||||||
|
return `${Math.floor(year / 10) * 10}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCases(locale: "pt-br" | "en"): Promise<CaseFile[]> {
|
||||||
|
const dir = path.join(CASE_ROOT, "reports");
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items: CaseFile[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.endsWith(".md")) continue;
|
||||||
|
try {
|
||||||
|
const full = path.join(dir, f);
|
||||||
|
const md = await readFile(full, "utf-8");
|
||||||
|
const st = await stat(full);
|
||||||
|
const { fm, body } = parseFrontmatter(md);
|
||||||
|
const slug = f.replace(/\.md$/, "");
|
||||||
|
const h1s = [...body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim());
|
||||||
|
const enTitle = h1s[0] ?? fm.topic ?? f;
|
||||||
|
const ptTitle = h1s[1] ?? h1s[0] ?? fm.topic_pt_br ?? fm.topic ?? f;
|
||||||
|
const year = inferYear(slug);
|
||||||
|
items.push({
|
||||||
|
slug,
|
||||||
|
topic: enTitle,
|
||||||
|
topic_pt_br: ptTitle,
|
||||||
|
opening: pickOpening(pickLocaleBody(body, locale), locale),
|
||||||
|
mtimeMs: st.mtimeMs,
|
||||||
|
year,
|
||||||
|
decade: decadeOf(year),
|
||||||
|
agency: inferAgency(slug),
|
||||||
|
});
|
||||||
|
} catch { /* skip broken file */ }
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue