W5.4 followup: hero illustration on /c/[slug] + sitemap fix
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:
parent
70b2fe687f
commit
8283237f87
3 changed files with 100 additions and 22 deletions
|
|
@ -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
|
* Extract the first prose paragraph from the body for the meta description
|
||||||
* + OG. We pick the locale-preferred sub-section's opener.
|
* + 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 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 canonical = `${SITE_URL}/c/${slug}`;
|
||||||
const lead = pickLead(body, locale).slice(0, 280);
|
const lead = pickLead(body, locale).slice(0, 280);
|
||||||
|
const heroArt = (await hasIllustration(slug)) ? `/api/static/processing/case-art/${slug}.png` : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
||||||
|
|
@ -118,21 +130,54 @@ export default async function CaseReportPage({
|
||||||
]} />
|
]} />
|
||||||
<AuthBar />
|
<AuthBar />
|
||||||
|
|
||||||
<article className="mx-auto max-w-3xl px-4 py-10 md:py-14">
|
{/* Full-bleed editorial hero — only when a painterly illustration
|
||||||
<header className="mb-10 md:mb-14">
|
exists for this case. Other cases get the plain title header. */}
|
||||||
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-4">
|
{heroArt && (
|
||||||
{locale === "en" ? "Declassified case file" : "Arquivo desclassificado"}
|
<div className="relative w-full overflow-hidden border-b border-[rgba(224,192,128,0.15)]">
|
||||||
{dateLabel && <> · {dateLabel}</>}
|
<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>
|
||||||
<h1 className="font-display text-4xl md:text-6xl font-semibold leading-[1.05] tracking-tight text-[#e7ecf3] mb-6">
|
</div>
|
||||||
{title}
|
)}
|
||||||
</h1>
|
|
||||||
{lead && (
|
<article className="mx-auto max-w-3xl px-4 py-10 md:py-14">
|
||||||
<p className="text-lg md:text-xl text-[#cbd2dd] leading-relaxed font-light max-w-2xl">
|
{!heroArt && (
|
||||||
{lead}
|
<header className="mb-10 md:mb-14">
|
||||||
</p>
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-4">
|
||||||
)}
|
{locale === "en" ? "Declassified case file" : "Arquivo desclassificado"}
|
||||||
</header>
|
{dateLabel && <> · {dateLabel}</>}
|
||||||
|
</div>
|
||||||
|
<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 ${heroArt ? "mb-10" : "mb-10 md:mb-14"}`}>
|
||||||
|
{lead}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="prose prose-invert prose-lg max-w-none
|
<div className="prose prose-invert prose-lg max-w-none
|
||||||
prose-headings:font-display prose-headings:font-semibold
|
prose-headings:font-display prose-headings:font-semibold
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ import { pgQuery } from "@/lib/retrieval/db";
|
||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top";
|
||||||
const CASE_ROOT = process.env.CASE_ROOT ?? "/data/ufo/case";
|
const CASE_ROOT = process.env.CASE_ROOT ?? "/data/ufo/case";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ interface FeaturedCaseData {
|
||||||
opening: string;
|
opening: string;
|
||||||
hero_doc_id: string | null;
|
hero_doc_id: string | null;
|
||||||
hero_page: number | 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";
|
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) };
|
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> {
|
async function loadFeatured(locale: "pt-br" | "en"): Promise<FeaturedCaseData | null> {
|
||||||
const dir = path.join(CASE_ROOT, "reports");
|
const dir = path.join(CASE_ROOT, "reports");
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,13 +80,15 @@ async function loadFeatured(locale: "pt-br" | "en"): Promise<FeaturedCaseData |
|
||||||
const st = await stat(full);
|
const st = await stat(full);
|
||||||
const { fm, body } = parseFrontmatter(md);
|
const { fm, body } = parseFrontmatter(md);
|
||||||
const docRef = extractFirstDocRef(body);
|
const docRef = extractFirstDocRef(body);
|
||||||
|
const slug = f.replace(/\.md$/, "");
|
||||||
items.push({
|
items.push({
|
||||||
slug: f.replace(/\.md$/, ""),
|
slug,
|
||||||
topic: fm.topic ?? f,
|
topic: fm.topic ?? f,
|
||||||
topic_pt_br: fm.topic_pt_br ?? null,
|
topic_pt_br: fm.topic_pt_br ?? null,
|
||||||
opening: pickOpening(body, locale),
|
opening: pickOpening(body, locale),
|
||||||
hero_doc_id: docRef?.doc_id ?? null,
|
hero_doc_id: docRef?.doc_id ?? null,
|
||||||
hero_page: docRef?.page ?? null,
|
hero_page: docRef?.page ?? null,
|
||||||
|
hero_illustration: await illustrationFor(slug),
|
||||||
mtimeMs: st.mtimeMs,
|
mtimeMs: st.mtimeMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -84,9 +103,13 @@ export async function FeaturedCase({ locale }: { locale: "pt-br" | "en" }) {
|
||||||
const c = await loadFeatured(locale);
|
const c = await loadFeatured(locale);
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const title = locale === "pt-br" ? (c.topic_pt_br ?? c.topic) : c.topic;
|
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
|
||||||
? `/api/static/processing/png/${c.hero_doc_id}/p-${String(c.hero_page).padStart(3, "0")}.png`
|
// thumbnail when we haven't generated art for this case yet.
|
||||||
: null;
|
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 (
|
return (
|
||||||
<section className="mx-auto max-w-7xl px-4 md:px-8 py-12 md:py-16">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Hero image column */}
|
{/* 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 ? (
|
{heroImg ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={heroImg}
|
src={heroImg}
|
||||||
alt={title}
|
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-br from-[#1a1f30] to-[#0a0e1a]" />
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0e1a] via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0e1a]/60 via-transparent to-transparent" />
|
||||||
{c.hero_doc_id && (
|
{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">
|
<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")}
|
{locale === "en" ? "Source · " : "Fonte · "}{c.hero_doc_id} / p{String(c.hero_page).padStart(3, "0")}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue