From 4fb3e12e15becea85a957b30715e3e3c4a920458 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Date: Fri, 29 May 2026 01:21:39 -0300 Subject: [PATCH] ux(bureau): search + agency/decade filters + sort, URL-synced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/app/bureau/page.tsx | 34 ++- web/components/case-library-browser.tsx | 303 ++++++++++++++++++++++++ web/components/case-library.tsx | 115 +-------- web/lib/cases.ts | 159 +++++++++++++ 4 files changed, 489 insertions(+), 122 deletions(-) create mode 100644 web/components/case-library-browser.tsx create mode 100644 web/lib/cases.ts diff --git a/web/app/bureau/page.tsx b/web/app/bureau/page.tsx index 071807f..cd6f934 100644 --- a/web/app/bureau/page.tsx +++ b/web/app/bureau/page.tsx @@ -1,36 +1,44 @@ /** * /bureau — Case file library. * - * Reader-facing list of every assembled narrative. No detective surfacing, - * no artefact dumps. Just stories with hooks. + * Magazine hero + filterable browser. The previous flat 80-card grid had no + * way to navigate; the new browser carries search, agency chips, decade + * chips, and sort, all URL-synced. */ 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 { loadCases } from "@/lib/cases"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export default async function BureauPage() { const locale = (await getLocale()) === "en" ? "en" : "pt-br"; + const cases = await loadCases(locale); return (
-
-
-

- {locale === "en" ? "▍ The Case Files" : "▍ Os Arquivos do Caso"} -

-

+

+
+
+ {locale === "en" ? "The case files" : "Os arquivos do caso"} +
+

{locale === "en" - ? "Narratives assembled from the declassified record. Each case is a story — written from primary documents, with citations linked to source pages." - : "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} declassified cases, narrated.` + : `${cases.length} casos desclassificados, narrados.`} +

+

+ {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."}

- -
+ +
); } diff --git a/web/components/case-library-browser.tsx b/web/components/case-library-browser.tsx new file mode 100644 index 0000000..0269eab --- /dev/null +++ b/web/components/case-library-browser.tsx @@ -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, + 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, + 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((params.get("agency") as Agency) ?? null); + const [decade, setDecade] = useState(params.get("decade")); + const [sort, setSort] = useState((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(); + 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(); + 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 ( +
+ {/* Search + sort row */} +
+
+ + + 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 && ( + + )} +
+ +
+ + {/* Agency chips */} + (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 */} + ({ + key: d, + label: d === "—" ? t.no_decade : d, + count: decadeCounts.get(d) ?? 0, + })), + ]} + active={decade} + onPick={setDecade} + /> + + {/* Result count + reset */} +
+ {t.showing(filtered.length, cases.length)} + {hasActiveFilter && ( + + )} +
+ + {/* Grid */} + {filtered.length === 0 ? ( +
+ {t.no_results} +
+ ) : ( +
+ {filtered.map((c) => )} +
+ )} +
+ ); +} + +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 ( +
+ {items.map((it) => { + const isActive = active === it.key; + return ( + + ); + })} +
+ ); +} + +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 ( + +
+ {meta.join(" · ")} +
+

+ {title} +

+ {c.opening && ( +

+ {c.opening} +

+ )} + + ); +} diff --git a/web/components/case-library.tsx b/web/components/case-library.tsx index c4a7800..9b06d1b 100644 --- a/web/components/case-library.tsx +++ b/web/components/case-library.tsx @@ -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, - * parses the YAML frontmatter for the case topic, extracts the opening - * paragraph as a preview hook, and renders a magazine-style grid. No - * 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). + * 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 { readdir, readFile, stat } from "node:fs/promises"; -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; body: string } { - const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/); - if (!m) return { fm: {}, body: md }; - const fm: Record = {}; - 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 { - 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); -} +import { loadCases, type CaseFile } from "@/lib/cases"; export async function CaseLibrary({ locale, @@ -118,7 +15,7 @@ export async function CaseLibrary({ }: { locale: "pt-br" | "en"; 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"; }) { const allCases = await loadCases(locale); diff --git a/web/lib/cases.ts b/web/lib/cases.ts new file mode 100644 index 0000000..ddee6b8 --- /dev/null +++ b/web/lib/cases.ts @@ -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; body: string } { + const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/); + if (!m) return { fm: {}, body: md }; + const fm: Record = {}; + 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 { + 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); +}