W5.4 followup: hero illustration on /c/[slug] + sitemap fix
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 34s
CI / Scripts — Python smoke (push) Failing after 6s
CI / Web — npm audit (push) Failing after 40s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

Hero illustration:
  - Painterly 16:9 editorial illustration generated via Nano Banana Pro
    for the featured case (green-fireballs-narrative): late-1940s
    desert night, vivid emerald fireball over silhouetted Sandia mesas,
    1948-era state-police sedan parked on US 66 shoulder with an
    officer in period uniform looking up, faint green glow on his
    face. Sandia Base 5 Miles roadsign. New Yorker-cover painterly
    register, NOT photorealistic, NOT sci-fi.
  - Stored at /data/disclosure/processing/case-art/<slug>.png, served
    through the existing /api/static/processing/ route. 2.7MB at 2K.
  - components/featured-case.tsx: prefers the illustration over the
    declassified-page thumbnail when present. Tags it "Editorial
    illustration" / "Ilustração editorial" so the reader knows it's
    not a photograph.
  - app/c/[slug]/page.tsx: full-bleed editorial hero at the top of
    the article when an illustration exists for the slug. Title sits
    on the image with gradient overlay; "Ilustração editorial" chip
    in the top-right corner labels the art honestly. When no
    illustration exists the page falls back to the plain title header.

Sitemap fix:
  - Added export const dynamic = "force-dynamic" + revalidate = 3600
    to app/sitemap.ts. Without these Next.js statically generated the
    sitemap at build time, when the DB and case-files volume were
    unreachable from the build container — which is why production
    was serving only the 9 static URLs instead of ~3000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-24 16:16:20 -03:00
parent 70b2fe687f
commit 8283237f87
3 changed files with 100 additions and 22 deletions

View file

@ -50,6 +50,17 @@ async function loadCase(slug: string): Promise<{ fm: Frontmatter; body: string }
}
}
async function hasIllustration(slug: string): Promise<boolean> {
const fs = await import("node:fs/promises");
const UFO_ROOT = process.env.UFO_ROOT || "/data/ufo";
try {
await fs.stat(path.join(UFO_ROOT, "processing", "case-art", `${slug}.png`));
return true;
} catch {
return false;
}
}
/**
* Extract the first prose paragraph from the body for the meta description
* + OG. We pick the locale-preferred sub-section's opener.
@ -109,6 +120,7 @@ export default async function CaseReportPage({
const dateLabel = fm.created_at ? new Date(fm.created_at).toLocaleDateString(locale === "pt-br" ? "pt-BR" : "en-US", { day: "numeric", month: "long", year: "numeric" }) : null;
const canonical = `${SITE_URL}/c/${slug}`;
const lead = pickLead(body, locale).slice(0, 280);
const heroArt = (await hasIllustration(slug)) ? `/api/static/processing/case-art/${slug}.png` : null;
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
@ -118,7 +130,39 @@ export default async function CaseReportPage({
]} />
<AuthBar />
{/* Full-bleed editorial hero only when a painterly illustration
exists for this case. Other cases get the plain title header. */}
{heroArt && (
<div className="relative w-full overflow-hidden border-b border-[rgba(224,192,128,0.15)]">
<div className="relative aspect-[16/9] md:aspect-[21/9] max-h-[640px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroArt}
alt={title}
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0e1a] via-[#0a0e1a]/30 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-[#0a0e1a]/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 px-4 md:px-8 pb-8 md:pb-12">
<div className="mx-auto max-w-3xl">
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-3">
{locale === "en" ? "Declassified case file" : "Arquivo desclassificado"}
{dateLabel && <> · {dateLabel}</>}
</div>
<h1 className="font-display text-4xl md:text-6xl font-semibold leading-[1.05] tracking-tight text-white drop-shadow-lg">
{title}
</h1>
</div>
</div>
<div className="absolute top-3 right-3 text-[9px] font-mono uppercase tracking-wider text-[#9aa6b8]/80 bg-[#0a0e1a]/70 px-2 py-1 rounded">
{locale === "en" ? "Editorial illustration" : "Ilustração editorial"}
</div>
</div>
</div>
)}
<article className="mx-auto max-w-3xl px-4 py-10 md:py-14">
{!heroArt && (
<header className="mb-10 md:mb-14">
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-4">
{locale === "en" ? "Declassified case file" : "Arquivo desclassificado"}
@ -127,12 +171,13 @@ export default async function CaseReportPage({
<h1 className="font-display text-4xl md:text-6xl font-semibold leading-[1.05] tracking-tight text-[#e7ecf3] mb-6">
{title}
</h1>
</header>
)}
{lead && (
<p className="text-lg md:text-xl text-[#cbd2dd] leading-relaxed font-light max-w-2xl">
<p className={`text-lg md:text-xl text-[#cbd2dd] leading-relaxed font-light max-w-2xl ${heroArt ? "mb-10" : "mb-10 md:mb-14"}`}>
{lead}
</p>
)}
</header>
<div className="prose prose-invert prose-lg max-w-none
prose-headings:font-display prose-headings:font-semibold

View file

@ -21,6 +21,12 @@ import { pgQuery } from "@/lib/retrieval/db";
import { readdir } from "node:fs/promises";
import path from "node:path";
// Without these, Next.js statically generates the sitemap at build time
// when the DB is unreachable from the build container — which is why we
// were getting only 9 static URLs in production.
export const dynamic = "force-dynamic";
export const revalidate = 3600;
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top";
const CASE_ROOT = process.env.CASE_ROOT ?? "/data/ufo/case";

View file

@ -16,6 +16,10 @@ interface FeaturedCaseData {
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";
@ -51,6 +55,19 @@ function extractFirstDocRef(body: string): { doc_id: string; page: number } | nu
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 {
@ -63,13 +80,15 @@ async function loadFeatured(locale: "pt-br" | "en"): Promise<FeaturedCaseData |
const st = await stat(full);
const { fm, body } = parseFrontmatter(md);
const docRef = extractFirstDocRef(body);
const slug = f.replace(/\.md$/, "");
items.push({
slug: f.replace(/\.md$/, ""),
slug,
topic: fm.topic ?? f,
topic_pt_br: fm.topic_pt_br ?? null,
opening: pickOpening(body, locale),
hero_doc_id: docRef?.doc_id ?? null,
hero_page: docRef?.page ?? null,
hero_illustration: await illustrationFor(slug),
mtimeMs: st.mtimeMs,
});
}
@ -84,9 +103,13 @@ 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;
const heroImg = c.hero_doc_id && c.hero_page
// 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;
: 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">
@ -116,19 +139,23 @@ export async function FeaturedCase({ locale }: { locale: "pt-br" | "en" }) {
</div>
{/* Hero image column */}
<div className="relative rounded-2xl overflow-hidden border border-[rgba(224,192,128,0.20)] bg-[#0d1220] aspect-[3/4] md:aspect-auto min-h-[280px] md:min-h-[420px]">
<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}
className="absolute inset-0 w-full h-full object-cover object-top opacity-90 group-hover:opacity-100 group-hover:scale-[1.02] transition-all duration-700"
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] via-transparent to-transparent" />
{c.hero_doc_id && (
<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>