204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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>
|
||
|
|
);
|
||
|
|
}
|