Case bodies hold EN and PT-BR scenes interleaved (## I. EN, ## I. PT, ...), but the reader rendered the whole body, so an EN visitor saw every scene twice — once in English, once in Portuguese — and vice-versa. Add pickLocaleBody(): split by section-number occurrence (first = EN, second = PT-BR, the narrator's mandated order), with language sniffing only as a fallback for unpaired/unnumbered sections, and full-body fallback when a body can't be split (English-only legacy files). Wire it into the case reader (body, lead, OG description) and the homepage card openings. Also fix extractBody: it discarded the English H1 by treating the second (PT-BR) H1 as "the first real heading". Future narrations keep both titles. Verified live: d44 and doc-65 render EN-only under locale=en and PT-only under locale=pt-br. Bulk-checked 89 files: 80 clean, 8 structurally clean (source-language verbatim quotes retained per spec), 1 English-only legacy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
7.3 KiB
TypeScript
177 lines
7.3 KiB
TypeScript
/**
|
|
* FeaturedCase — single big editorial card pinned at the top of the
|
|
* homepage. Magazine cover-story treatment.
|
|
*
|
|
* Pulls the most recent case_report from disk and renders it as a
|
|
* full-bleed editorial slab. Hero image is a real declassified page.
|
|
*/
|
|
import Link from "next/link";
|
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pickLocaleBody } from "@/lib/bilingual";
|
|
|
|
interface FeaturedCaseData {
|
|
slug: string;
|
|
topic: string;
|
|
topic_pt_br: string | null;
|
|
opening: string;
|
|
hero_doc_id: string | null;
|
|
hero_page: number | null;
|
|
/** When present, points at /api/static/processing/case-art/<slug>.png —
|
|
* a painterly editorial illustration generated for this case. Prefer
|
|
* this over the doc-page thumbnail. */
|
|
hero_illustration: string | null;
|
|
}
|
|
|
|
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 wantPt = locale === "pt-br";
|
|
// Look for the first prose paragraph under either the (EN) or (PT-BR) sub-section.
|
|
const marker = wantPt ? "(PT-BR)" : "(EN)";
|
|
const idx = body.indexOf(marker);
|
|
const slice = idx >= 0 ? body.slice(idx) : body;
|
|
const m = slice.match(/\n\n([^\n#>|`-][^\n]+(?:\n[^\n#>|`-][^\n]+)*)/);
|
|
return (m?.[1] ?? "").replace(/\s+/g, " ").trim().slice(0, 500);
|
|
}
|
|
|
|
function extractFirstDocRef(body: string): { doc_id: string; page: number } | null {
|
|
// Find the first [[doc-id/pNNN#cNNNN]] reference and return doc/page.
|
|
const m = body.match(/\[\[([a-z0-9][a-z0-9-]*)\/p(\d{3})/);
|
|
if (!m) return null;
|
|
return { doc_id: m[1], page: parseInt(m[2], 10) };
|
|
}
|
|
|
|
const UFO_ROOT = process.env.UFO_ROOT || "/data/ufo";
|
|
|
|
async function illustrationFor(slug: string): Promise<string | null> {
|
|
const fs = await import("node:fs/promises");
|
|
const p = path.join(UFO_ROOT, "processing", "case-art", `${slug}.png`);
|
|
try {
|
|
await fs.stat(p);
|
|
return `/api/static/processing/case-art/${slug}.png`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadFeatured(locale: "pt-br" | "en"): Promise<FeaturedCaseData | null> {
|
|
const dir = path.join(CASE_ROOT, "reports");
|
|
try {
|
|
const files = await readdir(dir);
|
|
const items: Array<FeaturedCaseData & { mtimeMs: number }> = [];
|
|
for (const f of files) {
|
|
if (!f.endsWith(".md")) continue;
|
|
const full = path.join(dir, f);
|
|
const md = await readFile(full, "utf-8");
|
|
const st = await stat(full);
|
|
const { fm, body } = parseFrontmatter(md);
|
|
const docRef = extractFirstDocRef(body);
|
|
const slug = f.replace(/\.md$/, "");
|
|
const h1s = [...body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
items.push({
|
|
slug,
|
|
topic: h1s[0] ?? fm.topic ?? f,
|
|
topic_pt_br: h1s[1] ?? h1s[0] ?? fm.topic_pt_br ?? null,
|
|
opening: pickOpening(pickLocaleBody(body, locale), locale),
|
|
hero_doc_id: docRef?.doc_id ?? null,
|
|
hero_page: docRef?.page ?? null,
|
|
hero_illustration: await illustrationFor(slug),
|
|
mtimeMs: st.mtimeMs,
|
|
});
|
|
}
|
|
// Pin a flagship to the featured slot (it has a curated hero painting).
|
|
// Falls back to most-recent if the flagship isn't present.
|
|
const FLAGSHIP = "green-fireballs-narrative";
|
|
const flagship = items.find((i) => i.slug === FLAGSHIP);
|
|
if (flagship) return flagship;
|
|
items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
return items[0] ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function FeaturedCase({ locale }: { locale: "pt-br" | "en" }) {
|
|
const c = await loadFeatured(locale);
|
|
if (!c) return null;
|
|
const title = locale === "pt-br" ? (c.topic_pt_br ?? c.topic) : c.topic;
|
|
// Prefer the painterly illustration; fall back to a declassified-page
|
|
// thumbnail when we haven't generated art for this case yet.
|
|
const heroImg = c.hero_illustration
|
|
?? (c.hero_doc_id && c.hero_page
|
|
? `/api/static/processing/png/${c.hero_doc_id}/p-${String(c.hero_page).padStart(3, "0")}.png`
|
|
: null);
|
|
const heroIsArt = c.hero_illustration !== null;
|
|
|
|
return (
|
|
<section className="mx-auto max-w-7xl px-4 md:px-8 py-12 md:py-16">
|
|
<Link
|
|
href={`/c/${c.slug}`}
|
|
className="group grid md:grid-cols-[1.1fr_1fr] gap-6 md:gap-10 items-stretch"
|
|
>
|
|
{/* Editorial text column */}
|
|
<div className="flex flex-col justify-center">
|
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-3">
|
|
{locale === "en" ? "Featured case · cover story" : "Caso em destaque · matéria de capa"}
|
|
</div>
|
|
<h2 className="font-display text-3xl md:text-5xl lg:text-6xl font-semibold leading-[1.05] tracking-tight text-[#e7ecf3] group-hover:text-[#e0c080] transition-colors mb-5">
|
|
{title}
|
|
</h2>
|
|
{c.opening && (
|
|
<p className="text-[17px] md:text-[18px] text-[#cbd2dd] leading-relaxed font-light max-w-2xl mb-6">
|
|
{c.opening}
|
|
</p>
|
|
)}
|
|
<div className="inline-flex items-center gap-2 text-[13px] font-mono text-[#e0c080]">
|
|
<span className="underline-offset-4 group-hover:underline">
|
|
{locale === "en" ? "Read the case file" : "Ler o caso completo"}
|
|
</span>
|
|
<span className="transition-transform group-hover:translate-x-1">→</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hero image column */}
|
|
<div className="relative rounded-2xl overflow-hidden border border-[rgba(224,192,128,0.20)] bg-[#0d1220] aspect-[16/10] md:aspect-auto min-h-[280px] md:min-h-[420px]">
|
|
{heroImg ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={heroImg}
|
|
alt={title}
|
|
fetchPriority="high"
|
|
width={1280}
|
|
height={720}
|
|
className={`absolute inset-0 w-full h-full object-cover ${heroIsArt ? "object-center" : "object-top"} opacity-95 group-hover:opacity-100 group-hover:scale-[1.02] transition-all duration-700`}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 bg-gradient-to-br from-[#1a1f30] to-[#0a0e1a]" />
|
|
)}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0e1a]/60 via-transparent to-transparent" />
|
|
{heroIsArt ? (
|
|
<div className="absolute bottom-3 left-3 right-3 text-[10px] font-mono text-[#9aa6b8] uppercase tracking-wider">
|
|
{locale === "en" ? "Editorial illustration" : "Ilustração editorial"}
|
|
</div>
|
|
) : c.hero_doc_id && (
|
|
<div className="absolute bottom-3 left-3 right-3 text-[10px] font-mono text-[#9aa6b8] uppercase tracking-wider">
|
|
{locale === "en" ? "Source · " : "Fonte · "}{c.hero_doc_id} / p{String(c.hero_page).padStart(3, "0")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</section>
|
|
);
|
|
}
|