disclosure-bureau/web/components/case-library.tsx

204 lines
6.8 KiB
TypeScript
Raw Normal View History

W5.1: enthusiast pivot — strip detective surfacing, magazine homepage User explicit: "1 bilhão de entusiastas pelo mundo ovni" — site is for the UFO-curious public, not for skeptics. The 8-detective scaffolding becomes invisible plumbing; the reader sees stories about what was observed. Reader-facing changes: New homepage (web/app/page.tsx) - SiteHeader: magazine-style top nav (no detective tiles) - HeroBanner: full-bleed editorial opener with declassified-page art background, display-serif headline, live stats row (122 docs, 2047 events, 1861 witnesses, 867 craft catalogued) - FeaturedCase: cover-story treatment of the most recent case_report, uses a real document page as hero image, links to /c/[slug] - PortalGrid: 6 thematic doorways into the archive — Sightings, Witnesses, Craft, Hot spots, Programs, Documents — each tile shows a real entity count and short editorial blurb - GreatestHits: top 9 most-cited events from the corpus (Kenneth Arnold 1947, Mantell 1948, …) as a magazine grid - Doc list kept but reframed as "the primary record" New sub-pages (5) - /sightings → events (2047), magazine grid - /witnesses → people (1861), compact table - /objects → uap_objects (867), magazine grid - /locations → locations (1757), compact table - /operations → organizations (1596), compact table - /documents → full doc list with thumbnails (mirrors homepage section for direct deep-link) All share <EntityListPage> shell with per-page i18n + JSON-LD ItemList Stripped detective surfacing - /jobs/[id]: "Sherlock Holmes / Dr. Watson" → "Investigation in progress" - chat-bubble: detective-named card → neutral "Investigação em andamento" - quick-launch: 7-kind detective dropdown → single "investigar um caso" input (kind=case_report hardcoded) - /bureau: rewritten as the case-file library (no artefact dumps) Typography + design - Fraunces variable serif loaded for display headings (`.font-display` class) - Gold-amber accent (#e0c080) unified as the brand colour - Asymmetric magazine grids (1+2+3 column, generous whitespace) - Hover micro-interactions (image scale on featured case, translateX on portal arrows) SEO + GEO - layout.tsx metadataBase + title.template + per-route Metadata exports - Organization JSON-LD on root layout - WebSite + SearchAction JSON-LD on homepage - CollectionPage + ItemList JSON-LD on every entity list page - openGraph + twitter cards, pt-BR primary + en-US alternate - ai:purpose meta tag for Generative Engine Optimization — declares the site as a citation-linked primary-source archive - robots: index + follow with large image preview The detectives themselves remain alive in the backend (runtime, DB, audit log), but the reader never sees "Holmes / Sun-Tzu / Watson" in the UI. The next phase will reorient case-writer to write as a single best-seller voice synthesising all the internal sources. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:09:46 +00:00
/**
* CaseLibrary public-facing case file library.
*
* 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).
*/
import Link from "next/link";
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
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);
items.push({
slug: f.replace(/\.md$/, ""),
topic: fm.topic ?? f,
topic_pt_br: fm.topic_pt_br ?? null,
opening: pickOpening(body, locale),
mtimeMs: st.mtimeMs,
});
} catch { /* skip broken file */ }
}
return items.sort((a, b) => b.mtimeMs - a.mtimeMs);
}
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 (/bureau) */
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>
);
}