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>
This commit is contained in:
parent
33dee46060
commit
ab4fe2a334
21 changed files with 1365 additions and 685 deletions
|
|
@ -1,327 +1,38 @@
|
||||||
/**
|
/**
|
||||||
* /bureau — Investigation Bureau hub.
|
* /bureau — Case file library.
|
||||||
*
|
*
|
||||||
* Full listing of every artefact: evidence, hypotheses, contradictions,
|
* Reader-facing list of every assembled narrative. No detective surfacing,
|
||||||
* witnesses, outliers, case reports, recent jobs. Each anchor-section
|
* no artefact dumps. Just stories with hooks.
|
||||||
* matches the hash-links from the homepage's counter row.
|
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
|
||||||
import { pgQuery } from "@/lib/retrieval/db";
|
|
||||||
import { AuthBar } from "@/components/auth-bar";
|
import { AuthBar } from "@/components/auth-bar";
|
||||||
import { BureauNav } from "@/components/bureau-nav";
|
import { BureauNav } from "@/components/bureau-nav";
|
||||||
|
import { CaseLibrary } from "@/components/case-library";
|
||||||
|
import { getLocale } from "@/components/locale-toggle";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
interface EvidenceRow {
|
|
||||||
evidence_id: string;
|
|
||||||
grade: string;
|
|
||||||
verbatim_excerpt: string;
|
|
||||||
source_page_id: string;
|
|
||||||
confidence_band: string | null;
|
|
||||||
}
|
|
||||||
interface HypothesisRow {
|
|
||||||
hypothesis_id: string;
|
|
||||||
question: string;
|
|
||||||
question_pt_br: string | null;
|
|
||||||
position: string;
|
|
||||||
position_pt_br: string | null;
|
|
||||||
prior: number | string | null;
|
|
||||||
posterior: number | string | null;
|
|
||||||
confidence_band: string | null;
|
|
||||||
status: string;
|
|
||||||
reviewed_by: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
interface ContradictionRow {
|
|
||||||
contradiction_id: string;
|
|
||||||
topic: string;
|
|
||||||
topic_pt_br: string | null;
|
|
||||||
resolution_status: string;
|
|
||||||
chunks: unknown;
|
|
||||||
}
|
|
||||||
interface GapRow {
|
|
||||||
gap_id: string;
|
|
||||||
description: string;
|
|
||||||
description_pt_br: string | null;
|
|
||||||
scope: unknown;
|
|
||||||
status: string;
|
|
||||||
suggested_next_move: string | null;
|
|
||||||
suggested_next_move_pt_br: string | null;
|
|
||||||
}
|
|
||||||
interface WitnessRow {
|
|
||||||
witness_id: string;
|
|
||||||
canonical_name: string | null;
|
|
||||||
entity_id: string | null;
|
|
||||||
credibility: string | null;
|
|
||||||
verdict: string | null;
|
|
||||||
verdict_pt_br: string | null;
|
|
||||||
}
|
|
||||||
interface JobRow {
|
|
||||||
job_id: string;
|
|
||||||
kind: string;
|
|
||||||
status: string;
|
|
||||||
payload: Record<string, unknown> | null;
|
|
||||||
created_at: string;
|
|
||||||
finished_at: string | null;
|
|
||||||
triggered_by: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BAND_TONE: Record<string, string> = {
|
|
||||||
high: "text-[#06d6a0] border-[#06d6a0]",
|
|
||||||
medium: "text-[#3fde6a] border-[#3fde6a]",
|
|
||||||
low: "text-[#ffa500] border-[#ffa500]",
|
|
||||||
speculation: "text-[#ff6ec7] border-[#ff6ec7]",
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRADE_TONE: Record<string, string> = {
|
|
||||||
A: "text-[#06d6a0] border-[#06d6a0]",
|
|
||||||
B: "text-[#3fde6a] border-[#3fde6a]",
|
|
||||||
C: "text-[#ffa500] border-[#ffa500]",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function BureauPage() {
|
export default async function BureauPage() {
|
||||||
// All artefacts. Server component — single round per query, no n+1.
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
||||||
const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([
|
|
||||||
pgQuery<HypothesisRow>(
|
|
||||||
`SELECT hypothesis_id, question, question_pt_br, position, position_pt_br,
|
|
||||||
prior, posterior, confidence_band, status, reviewed_by, created_at
|
|
||||||
FROM public.hypotheses ORDER BY created_at DESC LIMIT 100`,
|
|
||||||
).catch(() => []),
|
|
||||||
pgQuery<EvidenceRow>(
|
|
||||||
`SELECT evidence_id, grade, verbatim_excerpt, source_page_id, confidence_band
|
|
||||||
FROM public.evidence ORDER BY created_at DESC LIMIT 100`,
|
|
||||||
).catch(() => []),
|
|
||||||
pgQuery<ContradictionRow>(
|
|
||||||
`SELECT contradiction_id, topic, topic_pt_br, resolution_status, chunks
|
|
||||||
FROM public.contradictions ORDER BY created_at DESC LIMIT 100`,
|
|
||||||
).catch(() => []),
|
|
||||||
pgQuery<GapRow>(
|
|
||||||
`SELECT gap_id, description, description_pt_br, scope, status,
|
|
||||||
suggested_next_move, suggested_next_move_pt_br
|
|
||||||
FROM public.gaps ORDER BY created_at DESC LIMIT 100`,
|
|
||||||
).catch(() => []),
|
|
||||||
pgQuery<WitnessRow>(
|
|
||||||
`SELECT w.witness_id, e.canonical_name, e.entity_id, w.credibility,
|
|
||||||
w.verdict, w.verdict_pt_br
|
|
||||||
FROM public.witnesses w
|
|
||||||
LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk
|
|
||||||
ORDER BY w.created_at DESC LIMIT 100`,
|
|
||||||
).catch(() => []),
|
|
||||||
pgQuery<JobRow>(
|
|
||||||
`SELECT job_id, kind, status, payload, created_at, finished_at, triggered_by
|
|
||||||
FROM public.investigation_jobs ORDER BY created_at DESC LIMIT 25`,
|
|
||||||
).catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { readdir, stat, readFile } = await import("node:fs/promises");
|
|
||||||
const path = await import("node:path");
|
|
||||||
const reportsDir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports");
|
|
||||||
let reports: Array<{ slug: string; topic: string; mtimeMs: number; n_evidence: number; n_hypotheses: number }> = [];
|
|
||||||
try {
|
|
||||||
const files = await readdir(reportsDir);
|
|
||||||
const items = await Promise.all(
|
|
||||||
files.filter((f) => f.endsWith(".md")).map(async (f) => {
|
|
||||||
const full = path.join(reportsDir, f);
|
|
||||||
const st = await stat(full);
|
|
||||||
const head = (await readFile(full, "utf-8")).slice(0, 2000);
|
|
||||||
const topicMatch = head.match(/topic:\s*"([^"]+)"/);
|
|
||||||
const eMatch = head.match(/n_evidence:\s*(\d+)/);
|
|
||||||
const hMatch = head.match(/n_hypotheses:\s*(\d+)/);
|
|
||||||
return {
|
|
||||||
slug: f.replace(/\.md$/, ""),
|
|
||||||
topic: topicMatch?.[1] ?? f,
|
|
||||||
mtimeMs: st.mtimeMs,
|
|
||||||
n_evidence: Number(eMatch?.[1] ?? 0),
|
|
||||||
n_hypotheses: Number(hMatch?.[1] ?? 0),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
reports = items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
||||||
} catch { /* fine */ }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
||||||
<BureauNav crumbs={[{ label: "bureau" }]} />
|
<BureauNav crumbs={[{ label: locale === "en" ? "case files" : "casos" }]} />
|
||||||
<AuthBar />
|
<AuthBar />
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 pt-4">
|
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
|
||||||
|
<header className="mb-6 border-b border-[rgba(224,192,128,0.32)] pb-4">
|
||||||
<header className="mb-8 border-b border-[rgba(224,192,128,0.32)] pb-4">
|
<h1 className="font-mono text-3xl text-[#e0c080]">
|
||||||
<h1 className="font-mono text-3xl text-[#e0c080]">▍ The Investigation Bureau</h1>
|
{locale === "en" ? "▍ The Case Files" : "▍ Os Arquivos do Caso"}
|
||||||
|
</h1>
|
||||||
<p className="text-[#8896aa] text-sm mt-1">
|
<p className="text-[#8896aa] text-sm mt-1">
|
||||||
Live case folder · {ev.length} evidence · {hyp.length} hypotheses · {ctr.length} contradictions ·{" "}
|
{locale === "en"
|
||||||
{wit.length} witnesses · {gap.length} outliers · {reports.length} reports
|
? "Narratives assembled from the declassified record. Each case is a story — written from primary documents, with citations linked to source pages."
|
||||||
|
: "Narrativas montadas a partir do registro desclassificado. Cada caso é uma história — escrita a partir de documentos primários, com citações vinculadas às páginas-fonte."}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Case reports */}
|
<CaseLibrary locale={locale} layout="grid" />
|
||||||
<Section id="reports" title="Case reports" color="text-[#e0c080]">
|
|
||||||
{reports.length === 0 ? <Empty /> : reports.map((r) => (
|
|
||||||
<Link key={r.slug} href={`/c/${r.slug}`} className="block rounded border border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(224,192,128,0.04)] mb-2">
|
|
||||||
<div className="text-[10px] font-mono text-[#5a6678]">/c/{r.slug}</div>
|
|
||||||
<div className="text-[14px] text-[#e7ecf3] font-medium mt-1">{r.topic}</div>
|
|
||||||
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
|
|
||||||
{r.n_hypotheses} hypotheses · {r.n_evidence} evidence
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Hypotheses */}
|
|
||||||
<Section id="hypotheses" title="Hypotheses" color="text-[#7fdbff]">
|
|
||||||
{hyp.length === 0 ? <Empty /> : hyp.map((h) => {
|
|
||||||
const post = h.posterior !== null ? Number(h.posterior) : null;
|
|
||||||
const prior = h.prior !== null ? Number(h.prior) : null;
|
|
||||||
const delta = post !== null && prior !== null ? post - prior : null;
|
|
||||||
const bandTone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
|
|
||||||
return (
|
|
||||||
<Link key={h.hypothesis_id} href={`/h/${h.hypothesis_id}`}
|
|
||||||
className="block rounded border border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(127,219,255,0.04)] mb-2">
|
|
||||||
<div className="flex items-baseline justify-between gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{h.hypothesis_id} · {h.status}</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{h.confidence_band && (
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>{h.confidence_band}</span>
|
|
||||||
)}
|
|
||||||
{h.reviewed_by && (
|
|
||||||
<span className="text-[9px] font-mono text-[#ff3344]">↳ {h.reviewed_by}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[13px] text-[#e7ecf3] leading-snug">{h.position_pt_br || h.position}</div>
|
|
||||||
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
|
|
||||||
prior {prior?.toFixed(2) ?? "—"} → posterior {post?.toFixed(2) ?? "—"}
|
|
||||||
{delta !== null && <span className={delta > 0 ? " text-[#06d6a0]" : delta < 0 ? " text-[#ff6ec7]" : ""}> · Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)}</span>}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Evidence */}
|
|
||||||
<Section id="evidence" title="Evidence" color="text-[#06d6a0]">
|
|
||||||
{ev.length === 0 ? <Empty /> : ev.map((e) => {
|
|
||||||
const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
|
|
||||||
return (
|
|
||||||
<div key={e.evidence_id} className="rounded border border-[rgba(6,214,160,0.18)] bg-[#0d1220] p-3 mb-2">
|
|
||||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{e.evidence_id} · {e.source_page_id}</span>
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>Grade {e.grade}</span>
|
|
||||||
</div>
|
|
||||||
<blockquote className="text-[12px] text-[#cbd2dd] italic border-l-2 border-[#06d6a0] pl-2 mt-1">
|
|
||||||
“{e.verbatim_excerpt}”
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Contradictions */}
|
|
||||||
<Section id="contradictions" title="Contradictions" color="text-[#ff8a4d]">
|
|
||||||
{ctr.length === 0 ? <Empty /> : ctr.map((c) => {
|
|
||||||
const n = Array.isArray(c.chunks) ? c.chunks.length : 0;
|
|
||||||
return (
|
|
||||||
<div key={c.contradiction_id} className="rounded border border-[rgba(255,138,77,0.18)] bg-[#0d1220] p-3 mb-2">
|
|
||||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
|
|
||||||
<span className="text-[10px] font-mono text-[#9aa6b8]">{n} positions · {c.resolution_status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[13px] text-[#e7ecf3] leading-snug">{c.topic_pt_br || c.topic}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Outliers */}
|
|
||||||
<Section id="outliers" title="Outliers" color="text-[#ffd23f]">
|
|
||||||
{gap.length === 0 ? <Empty /> : gap.map((g) => {
|
|
||||||
const s = (g.scope ?? {}) as Record<string, unknown>;
|
|
||||||
const title = (s.title_pt_br as string) || (s.title as string) || g.description_pt_br || g.description;
|
|
||||||
const why = (s.why_surprising_pt_br as string) || (s.why_surprising as string) || null;
|
|
||||||
const isOutlier = s.kind === "outlier";
|
|
||||||
const nextMove = g.suggested_next_move_pt_br || g.suggested_next_move;
|
|
||||||
return (
|
|
||||||
<div key={g.gap_id} className="rounded border border-[rgba(255,210,63,0.25)] bg-[#0d1220] p-3 mb-2">
|
|
||||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{g.gap_id}{isOutlier && " · outlier"}</span>
|
|
||||||
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[13px] text-[#e7ecf3] leading-snug">{title}</div>
|
|
||||||
{why && (
|
|
||||||
<div className="text-[11px] text-[#cbd2dd] mt-1 leading-relaxed">{why}</div>
|
|
||||||
)}
|
|
||||||
{nextMove && (
|
|
||||||
<div className="text-[10px] font-mono text-[#06d6a0] mt-1">→ {nextMove}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Witnesses */}
|
|
||||||
<Section id="witnesses" title="Witness analyses" color="text-[#9b5de5]">
|
|
||||||
{wit.length === 0 ? <Empty /> : wit.map((w) => (
|
|
||||||
<div key={w.witness_id} className="rounded border border-[rgba(155,93,229,0.18)] bg-[#0d1220] p-3 mb-2">
|
|
||||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{w.witness_id}</span>
|
|
||||||
<span className="text-[10px] font-mono text-[#9b5de5]">{w.credibility ?? "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[13px] text-[#e7ecf3] font-medium">
|
|
||||||
{w.entity_id ? <Link href={`/e/people/${w.entity_id}`} className="hover:underline">{w.canonical_name ?? w.entity_id}</Link> : (w.canonical_name ?? "—")}
|
|
||||||
</div>
|
|
||||||
{(w.verdict_pt_br || w.verdict) && <blockquote className="text-[12px] text-[#cbd2dd] italic mt-1 border-l-2 border-[#9b5de5] pl-2">{w.verdict_pt_br || w.verdict}</blockquote>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Recent jobs */}
|
|
||||||
<Section id="jobs" title="Recent investigation jobs" color="text-[#9aa6b8]">
|
|
||||||
{jobs.length === 0 ? <Empty /> : (
|
|
||||||
<div className="rounded border border-[rgba(127,219,255,0.08)] bg-[#0d1220] overflow-hidden">
|
|
||||||
<table className="w-full text-[11px] font-mono">
|
|
||||||
<thead className="text-[#5a6678] border-b border-[rgba(127,219,255,0.1)]">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-3 py-2">job</th>
|
|
||||||
<th className="text-left px-3 py-2">kind</th>
|
|
||||||
<th className="text-left px-3 py-2">status</th>
|
|
||||||
<th className="text-left px-3 py-2">created</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{jobs.map((j) => (
|
|
||||||
<tr key={j.job_id} className="border-t border-[rgba(127,219,255,0.04)] hover:bg-[rgba(127,219,255,0.03)]">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Link href={`/jobs/${j.job_id}`} className="text-[#7fdbff] hover:underline">{j.job_id.slice(0, 8)}…</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-[#cbd2dd]">{j.kind}</td>
|
|
||||||
<td className={`px-3 py-2 ${
|
|
||||||
j.status === "complete" ? "text-[#06d6a0]" :
|
|
||||||
j.status === "failed" ? "text-[#ff6ec7]" :
|
|
||||||
j.status === "running" ? "text-[#ffd23f]" :
|
|
||||||
"text-[#9aa6b8]"
|
|
||||||
}`}>{j.status}</td>
|
|
||||||
<td className="px-3 py-2 text-[#5a6678]">{new Date(j.created_at).toLocaleString("pt-BR")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ id, title, color, children }: { id: string; title: string; color: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<section id={id} className="mb-10 scroll-mt-20">
|
|
||||||
<h2 className={`font-mono text-xs uppercase tracking-[0.18em] ${color} mb-3`}>// {title}</h2>
|
|
||||||
<div>{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Empty() {
|
|
||||||
return <div className="text-[11px] font-mono text-[#5a6678]">_(none yet)_</div>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
66
web/app/documents/page.tsx
Normal file
66
web/app/documents/page.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { listDocuments, readDocument } from "@/lib/wiki";
|
||||||
|
import { getLocale } from "@/components/locale-toggle";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { BureauNav } from "@/components/bureau-nav";
|
||||||
|
import { summarize, pickPitch } from "@/lib/doc-summary";
|
||||||
|
import { DocListFilters } from "@/components/doc-list-filters";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Documentos desclassificados — Arquivo UAP/UFO",
|
||||||
|
description:
|
||||||
|
"Cada memorando, telegrama e relatório desclassificado do Departamento de Guerra dos EUA sobre UAP/UFO. " +
|
||||||
|
"Páginas indexadas, citações vinculadas, busca por texto.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DocumentsPage() {
|
||||||
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
||||||
|
const ids = await listDocuments();
|
||||||
|
const summaryLang: "pt" | "en" = locale === "en" ? "en" : "pt";
|
||||||
|
|
||||||
|
const docs = await Promise.all(
|
||||||
|
ids.map(async (id) => {
|
||||||
|
const f = await readDocument(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: (f?.fm.canonical_title as string) ?? id,
|
||||||
|
pages: (f?.fm.page_count as number) ?? 0,
|
||||||
|
collection: (f?.fm.collection as string) ?? "uncategorized",
|
||||||
|
classification: (f?.fm.highest_classification as string) ?? "—",
|
||||||
|
summary: pickPitch(f?.fm as Record<string, unknown> | undefined, summaryLang) ?? (f?.body ? summarize(f.body, summaryLang) : ""),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const totalPages = docs.reduce((s, d) => s + d.pages, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<SiteHeader locale={locale} />
|
||||||
|
<BureauNav crumbs={[{ label: locale === "en" ? "documents" : "documentos" }]} />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl px-4 md:px-8 py-10 md:py-14">
|
||||||
|
<header className="mb-10">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#5a6678] mb-3">
|
||||||
|
{locale === "en" ? "// The primary record" : "// O registro primário"}
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-4xl md:text-6xl font-semibold text-[#e7ecf3] leading-tight mb-3">
|
||||||
|
{locale === "en" ? "Documents" : "Documentos"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-[#9aa6b8] max-w-2xl">
|
||||||
|
{locale === "en"
|
||||||
|
? `${ids.length} declassified files · ${totalPages.toLocaleString("pt-BR")} pages · every memo, telegram and report.`
|
||||||
|
: `${ids.length} arquivos desclassificados · ${totalPages.toLocaleString("pt-BR")} páginas · cada memorando, telegrama e relatório.`}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DocListFilters docs={docs} />
|
||||||
|
</div>
|
||||||
|
<noscript>
|
||||||
|
<Link href="/" className="hidden">{locale === "en" ? "Home" : "Início"}</Link>
|
||||||
|
</noscript>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,11 @@ html, body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--font-display), "Fraunces", "Georgia", "Times New Roman", serif;
|
||||||
|
font-feature-settings: "ss01", "ss02";
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse 60% 50% at 92% 8%, rgba(0, 255, 156, 0.05) 0%, transparent 65%),
|
radial-gradient(ellipse 60% 50% at 92% 8%, rgba(0, 255, 156, 0.05) 0%, transparent 65%),
|
||||||
|
|
|
||||||
|
|
@ -52,50 +52,16 @@ export default async function JobPage({
|
||||||
const job = rows[0];
|
const job = rows[0];
|
||||||
if (!job) notFound();
|
if (!job) notFound();
|
||||||
|
|
||||||
const detective = job.kind === "hypothesis_tournament" ? "holmes"
|
// Reader-facing: no detective surfacing. Every kind reads as "case
|
||||||
: job.kind === "contradiction_scan" ? "dupin"
|
// investigation in progress" with neutral copy. Detective identities
|
||||||
: job.kind === "red_team_review" ? "schneier"
|
// remain internal in the runtime (audit log) but never reach the UI.
|
||||||
: job.kind === "witness_analysis" ? "poirot"
|
const detectiveName = "Investigation in progress";
|
||||||
: job.kind === "outlier_scan" ? "taleb"
|
|
||||||
: job.kind === "calibrate_hypothesis" ? "tetlock"
|
|
||||||
: job.kind === "case_report" ? "case-writer"
|
|
||||||
: "locard";
|
|
||||||
const detectiveName =
|
|
||||||
detective === "holmes" ? "Sherlock Holmes" :
|
|
||||||
detective === "dupin" ? "C. Auguste Dupin" :
|
|
||||||
detective === "schneier" ? "Bruce Schneier" :
|
|
||||||
detective === "poirot" ? "Hercule Poirot" :
|
|
||||||
detective === "taleb" ? "Nassim Taleb" :
|
|
||||||
detective === "tetlock" ? "Philip Tetlock" :
|
|
||||||
detective === "case-writer" ? "Dr. Watson (Case-Writer)" :
|
|
||||||
"Edmond Locard";
|
|
||||||
const detectiveSubtitle =
|
const detectiveSubtitle =
|
||||||
detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" :
|
job.kind === "case_report" ? "Assembling a narrative case file from the primary record" :
|
||||||
detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" :
|
job.kind === "evidence_chain" ? "Pulling verbatim citations from the document" :
|
||||||
detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" :
|
"Reading the archive for this case";
|
||||||
detective === "poirot" ? "Witness analysis · credibility / access / bias / corroboration" :
|
const detectiveTone = "text-[#e0c080]";
|
||||||
detective === "taleb" ? "Outlier scan · chunks that violate the dominant model" :
|
const detectiveBg = "from-[rgba(224,192,128,0.06)]";
|
||||||
detective === "tetlock" ? "Calibration · honest Bayesian update with action recommendation" :
|
|
||||||
detective === "case-writer" ? "Case narrative · five-act Watson assembly of all bureau artefacts" :
|
|
||||||
"Evidence chain · verbatim quotes with chain of custody (Locard)";
|
|
||||||
const detectiveTone =
|
|
||||||
detective === "holmes" ? "text-[#7fdbff]" :
|
|
||||||
detective === "dupin" ? "text-[#ff8a4d]" :
|
|
||||||
detective === "schneier" ? "text-[#ff3344]" :
|
|
||||||
detective === "poirot" ? "text-[#9b5de5]" :
|
|
||||||
detective === "taleb" ? "text-[#ffd23f]" :
|
|
||||||
detective === "tetlock" ? "text-[#26d4cc]" :
|
|
||||||
detective === "case-writer" ? "text-[#e0c080]" :
|
|
||||||
"text-[#06d6a0]";
|
|
||||||
const detectiveBg =
|
|
||||||
detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" :
|
|
||||||
detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" :
|
|
||||||
detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" :
|
|
||||||
detective === "poirot" ? "from-[rgba(155,93,229,0.08)]" :
|
|
||||||
detective === "taleb" ? "from-[rgba(255,210,63,0.08)]" :
|
|
||||||
detective === "tetlock" ? "from-[rgba(38,212,204,0.08)]" :
|
|
||||||
detective === "case-writer" ? "from-[rgba(224,192,128,0.08)]" :
|
|
||||||
"from-[rgba(6,214,160,0.08)]";
|
|
||||||
const payload = (job.payload ?? {}) as Record<string, unknown>;
|
const payload = (job.payload ?? {}) as Record<string, unknown>;
|
||||||
const question = (payload.question ?? payload.topic ?? payload.hypothesis_id ?? payload.person_id) as string | undefined;
|
const question = (payload.question ?? payload.topic ?? payload.hypothesis_id ?? payload.person_id) as string | undefined;
|
||||||
const questionLabel =
|
const questionLabel =
|
||||||
|
|
@ -127,7 +93,7 @@ export default async function JobPage({
|
||||||
<p className="text-[12px] text-[#9aa6b8] mt-1 font-mono">{detectiveSubtitle}</p>
|
<p className="text-[12px] text-[#9aa6b8] mt-1 font-mono">{detectiveSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${detectiveTone} border-current`}>
|
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${detectiveTone} border-current`}>
|
||||||
{detective}
|
{job.kind}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,98 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { JetBrains_Mono, Inter } from "next/font/google";
|
import { JetBrains_Mono, Inter, Fraunces } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { LocaleToggle, getLocale } from "@/components/locale-toggle";
|
import { LocaleToggle, getLocale } from "@/components/locale-toggle";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-display",
|
||||||
|
weight: ["400", "600", "700", "900"],
|
||||||
|
axes: ["SOFT", "WONK"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "The Disclosure Bureau",
|
metadataBase: new URL(SITE_URL),
|
||||||
|
title: {
|
||||||
|
default: "The Disclosure Bureau — UAP/UFO desclassificado",
|
||||||
|
template: "%s · The Disclosure Bureau",
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
"Investigative case folder of declassified UAP/UFO documents, " +
|
"122 documentos desclassificados do Departamento de Guerra dos EUA sobre UAP/UFO. " +
|
||||||
"worked by 8 AI detectives (Holmes · Locard · Dupin · Schneier · " +
|
"Pilotos, oficiais e físicos relatam o que viram. Avistamentos, testemunhas, " +
|
||||||
"Poirot · Taleb · Tetlock · Case-Writer).",
|
"objetos catalogados — arquivos abertos da divulgação.",
|
||||||
|
keywords: [
|
||||||
|
"UAP", "UFO", "ovni", "desclassificado", "war.gov", "Pentagon",
|
||||||
|
"Kenneth Arnold", "Mantell", "green fireballs", "Sandia",
|
||||||
|
"Project Blue Book", "Robertson Panel", "AATIP",
|
||||||
|
"documentos desclassificados", "avistamento", "testemunha",
|
||||||
|
"disclosure", "divulgação UFO",
|
||||||
|
],
|
||||||
|
authors: [{ name: "The Disclosure Bureau" }],
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
siteName: "The Disclosure Bureau",
|
||||||
|
title: "The Disclosure Bureau — UAP/UFO desclassificado",
|
||||||
|
description:
|
||||||
|
"122 documentos desclassificados. Pilotos, oficiais, físicos relatam o que viram.",
|
||||||
|
locale: "pt_BR",
|
||||||
|
alternateLocale: ["en_US"],
|
||||||
|
url: SITE_URL,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "The Disclosure Bureau",
|
||||||
|
description: "Arquivos UAP/UFO desclassificados, narrados a partir do registro público.",
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
languages: { "pt-BR": "/", "en-US": "/" },
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
// GEO (Generative Engine Optimization) — explicit primary-source statement
|
||||||
|
// so retrieval-augmented assistants understand what the site is.
|
||||||
|
"ai:purpose":
|
||||||
|
"Public, citation-linked archive of declassified UAP/UFO documents from the US Department of War. Each case file is grounded in primary-source memos with verbatim quotes and bbox-cropped imagery.",
|
||||||
|
"ai:license": "Documents are US Government works in the public domain; site narrative © Disclosure Bureau, CC-BY 4.0.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
return (
|
return (
|
||||||
<html lang={locale === "en" ? "en" : "pt-BR"} className="dark" data-locale={locale}>
|
<html lang={locale === "en" ? "en" : "pt-BR"} className="dark" data-locale={locale}>
|
||||||
<body className={`${inter.variable} ${mono.variable}`}>
|
<head>
|
||||||
|
{/* JSON-LD: organization-level schema. Per-page Article/Event schemas
|
||||||
|
are added in their own routes. */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "The Disclosure Bureau",
|
||||||
|
url: SITE_URL,
|
||||||
|
description:
|
||||||
|
"Public archive of declassified UAP/UFO documents from the US Department of War, " +
|
||||||
|
"with narrated case files grounded in primary sources.",
|
||||||
|
sameAs: [],
|
||||||
|
}) }}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className={`${inter.variable} ${mono.variable} ${fraunces.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<div className="fixed bottom-3 left-3 z-40 opacity-70 hover:opacity-100 transition">
|
<div className="fixed bottom-3 left-3 z-40 opacity-70 hover:opacity-100 transition">
|
||||||
|
|
|
||||||
26
web/app/locations/page.tsx
Normal file
26
web/app/locations/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { EntityListPage } from "@/components/entity-list-page";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Locais — hot spots UAP/UFO no mapa desclassificado",
|
||||||
|
description:
|
||||||
|
"Onde o céu acendeu: Sandia, Roswell, Rendlesham, Phoenix, águas do USS Nimitz, " +
|
||||||
|
"e cada cidade ou base citada nos arquivos.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocationsPage() {
|
||||||
|
return (
|
||||||
|
<EntityListPage
|
||||||
|
entityClass="location"
|
||||||
|
folder="locations"
|
||||||
|
title_en="Hot spots"
|
||||||
|
title_pt="Locais"
|
||||||
|
subtitle_en="Where the sky lit up. Bases, cities and coastlines named in the declassified record."
|
||||||
|
subtitle_pt="Onde o céu acendeu. Bases, cidades e litorais nomeados no registro desclassificado."
|
||||||
|
min_mentions={5}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web/app/objects/page.tsx
Normal file
26
web/app/objects/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { EntityListPage } from "@/components/entity-list-page";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Objetos UAP — discos, charutos, esferas, tic-tacs",
|
||||||
|
description:
|
||||||
|
"Catálogo dos objetos UAP/UFO descritos nos documentos desclassificados. " +
|
||||||
|
"Discos voadores, objetos charuto, esferas luminosas, tic-tacs do Nimitz.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ObjectsPage() {
|
||||||
|
return (
|
||||||
|
<EntityListPage
|
||||||
|
entityClass="uap_object"
|
||||||
|
folder="uap-objects"
|
||||||
|
title_en="Craft catalogued"
|
||||||
|
title_pt="Objetos UAP catalogados"
|
||||||
|
subtitle_en="Every distinct UAP described in the declassified record, by shape and behaviour: discs, cigars, spheres, triangles, tic-tacs."
|
||||||
|
subtitle_pt="Cada UAP descrito no registro desclassificado, por forma e comportamento: discos, charutos, esferas, triângulos, tic-tacs."
|
||||||
|
min_mentions={1}
|
||||||
|
variant="magazine"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web/app/operations/page.tsx
Normal file
26
web/app/operations/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { EntityListPage } from "@/components/entity-list-page";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Programas secretos — Project Blue Book, AATIP, Robertson Panel",
|
||||||
|
description:
|
||||||
|
"A máquina oficial da divulgação: organizações, painéis, comissões e programas " +
|
||||||
|
"que investigaram UAP/UFO ao longo de 80 anos.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OperationsPage() {
|
||||||
|
return (
|
||||||
|
<EntityListPage
|
||||||
|
entityClass="organization"
|
||||||
|
folder="organizations"
|
||||||
|
title_en="Programs & agencies"
|
||||||
|
title_pt="Programas e agências"
|
||||||
|
subtitle_en="The official machinery of disclosure — Project Blue Book, the Robertson Panel, AATIP, and every other body that studied UAP."
|
||||||
|
subtitle_pt="A máquina oficial da divulgação — Project Blue Book, Robertson Panel, AATIP e cada outro órgão que estudou UAP."
|
||||||
|
min_mentions={5}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
web/app/page.tsx
151
web/app/page.tsx
|
|
@ -1,24 +1,45 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { listDocuments, readDocument } from "@/lib/wiki";
|
import { listDocuments, readDocument } from "@/lib/wiki";
|
||||||
import { ChatBubble } from "@/components/chat-bubble";
|
import { ChatBubble } from "@/components/chat-bubble";
|
||||||
import { AuthBar } from "@/components/auth-bar";
|
|
||||||
import { BatchProgressBanner } from "@/components/batch-progress-banner";
|
import { BatchProgressBanner } from "@/components/batch-progress-banner";
|
||||||
import { getLocale } from "@/components/locale-toggle";
|
import { getLocale } from "@/components/locale-toggle";
|
||||||
import { summarize, pickPitch } from "@/lib/doc-summary";
|
import { summarize, pickPitch } from "@/lib/doc-summary";
|
||||||
import { DocListFilters } from "@/components/doc-list-filters";
|
import { DocListFilters } from "@/components/doc-list-filters";
|
||||||
import { BureauSnapshot } from "@/components/bureau-snapshot";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { HeroBanner } from "@/components/hero-banner";
|
||||||
|
import { FeaturedCase } from "@/components/featured-case";
|
||||||
|
import { PortalGrid, type PortalCounts } from "@/components/portal-grid";
|
||||||
|
import { GreatestHits } from "@/components/greatest-hits";
|
||||||
|
import { pgQuery } from "@/lib/retrieval/db";
|
||||||
|
|
||||||
// Read wiki/ filesystem at request time, not build time.
|
// Read wiki/ filesystem + DB at request time, not build time.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function loadCounts(documentsCount: number): Promise<PortalCounts> {
|
||||||
|
const rows = await pgQuery<{ entity_class: string; c: string }>(
|
||||||
|
`SELECT entity_class, COUNT(*)::text AS c
|
||||||
|
FROM public.entities
|
||||||
|
GROUP BY entity_class`,
|
||||||
|
).catch(() => [] as Array<{ entity_class: string; c: string }>);
|
||||||
|
const by: Record<string, number> = {};
|
||||||
|
for (const r of rows) by[r.entity_class] = Number(r.c);
|
||||||
|
return {
|
||||||
|
events: by.event ?? 0,
|
||||||
|
people: by.person ?? 0,
|
||||||
|
uap_objects: by.uap_object ?? 0,
|
||||||
|
locations: by.location ?? 0,
|
||||||
|
operations: by.organization ?? 0,
|
||||||
|
documents: documentsCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const ids = await listDocuments();
|
const ids = await listDocuments();
|
||||||
const locale = await getLocale();
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
||||||
const summaryLang: "pt" | "en" = locale === "en" ? "en" : "pt";
|
|
||||||
|
|
||||||
const docs = await Promise.all(
|
const docs = await Promise.all(
|
||||||
ids.map(async (id) => {
|
ids.map(async (id) => {
|
||||||
const f = await readDocument(id);
|
const f = await readDocument(id);
|
||||||
|
const summaryLang: "pt" | "en" = locale === "en" ? "en" : "pt";
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: (f?.fm.canonical_title as string) ?? id,
|
title: (f?.fm.canonical_title as string) ?? id,
|
||||||
|
|
@ -30,73 +51,69 @@ export default async function Home() {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const counts = await loadCounts(ids.length);
|
||||||
<main className="min-h-screen p-6 md:p-10 max-w-[1600px] mx-auto">
|
const totalPages = docs.reduce((s, d) => s + d.pages, 0);
|
||||||
<header className="mb-12 border-b border-[rgba(0,255,156,0.32)] pb-6">
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
|
||||||
<div className="font-mono text-[10px] text-[#5a6678] tracking-[0.18em] uppercase">
|
|
||||||
// THE DISCLOSURE BUREAU // CLASSIFIED ARCHIVE //
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href="/search"
|
|
||||||
className="font-mono text-xs px-3 py-1.5 border border-[#ffa500] text-[#ffa500] hover:bg-[rgba(255,165,0,0.10)] rounded"
|
|
||||||
title="Hybrid search (or ⌘K anywhere)"
|
|
||||||
>
|
|
||||||
🔍 search
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/timeline"
|
|
||||||
className="font-mono text-xs px-3 py-1.5 border border-[#ff8a4d] text-[#ff8a4d] hover:bg-[rgba(255,138,77,0.10)] rounded"
|
|
||||||
>
|
|
||||||
📅 timeline
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/graph"
|
|
||||||
className="font-mono text-xs px-3 py-1.5 border border-[#7fdbff] text-[#7fdbff] hover:bg-[rgba(127,219,255,0.10)] rounded"
|
|
||||||
>
|
|
||||||
🕸 graph
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/admin/stats"
|
|
||||||
className="font-mono text-xs px-3 py-1.5 border border-[#a78bfa] text-[#a78bfa] hover:bg-[rgba(167,139,250,0.10)] rounded"
|
|
||||||
title="Corpus analytics"
|
|
||||||
>
|
|
||||||
📊 stats
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/bureau"
|
|
||||||
className="font-mono text-xs px-3 py-1.5 border border-[#e0c080] text-[#e0c080] hover:bg-[rgba(224,192,128,0.10)] rounded"
|
|
||||||
title="Investigation Bureau — detectives, hypotheses, evidence, contradictions, outliers, case reports"
|
|
||||||
>
|
|
||||||
🔎 bureau
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/admin/batch"
|
|
||||||
className="font-mono text-xs px-3 py-1.5 border border-[rgba(0,255,156,0.30)] text-[#00ff9c] hover:bg-[rgba(0,255,156,0.10)] rounded"
|
|
||||||
title="Batch rebuild progress"
|
|
||||||
>
|
|
||||||
📈 batch
|
|
||||||
</Link>
|
|
||||||
<AuthBar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 className="font-mono text-3xl text-[#00ff9c] mb-2">
|
|
||||||
▍ The Disclosure Bureau
|
|
||||||
</h1>
|
|
||||||
<p className="text-[#8896aa] text-sm">
|
|
||||||
{docs.length} declassified documents · {docs.reduce((s, d) => s + d.pages, 0)} pages ·
|
|
||||||
investigated by 8 AI detectives (Holmes · Locard · Dupin · Schneier · Poirot · Taleb · Tetlock · Case-Writer)
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<BureauSnapshot />
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<SiteHeader locale={locale} />
|
||||||
|
|
||||||
|
<HeroBanner
|
||||||
|
locale={locale}
|
||||||
|
stats={{
|
||||||
|
documents: counts.documents,
|
||||||
|
events: counts.events,
|
||||||
|
people: counts.people,
|
||||||
|
uap_objects: counts.uap_objects,
|
||||||
|
}}
|
||||||
|
heroDocId="dow-uap-d017-general-correspondence-of-sandia"
|
||||||
|
heroPage={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeaturedCase locale={locale} />
|
||||||
|
|
||||||
|
<PortalGrid counts={counts} locale={locale} />
|
||||||
|
|
||||||
|
<GreatestHits locale={locale} limit={9} />
|
||||||
|
|
||||||
<BatchProgressBanner />
|
<BatchProgressBanner />
|
||||||
|
|
||||||
|
{/* Document list — kept but reframed as "the primary record" */}
|
||||||
|
<section className="mx-auto max-w-7xl px-4 md:px-8 py-12 md:py-16 border-t border-[rgba(224,192,128,0.10)]">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#5a6678] mb-2">
|
||||||
|
{locale === "en" ? "// The primary record" : "// O registro primário"}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl md:text-3xl text-[#e7ecf3] mb-1">
|
||||||
|
{locale === "en" ? "Every document, indexed and searchable" : "Cada documento, indexado e buscável"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#9aa6b8] text-sm mb-6">
|
||||||
|
{locale === "en"
|
||||||
|
? `${ids.length} declassified files · ${totalPages.toLocaleString("pt-BR")} pages`
|
||||||
|
: `${ids.length} arquivos desclassificados · ${totalPages.toLocaleString("pt-BR")} páginas`}
|
||||||
|
</p>
|
||||||
<DocListFilters docs={docs} />
|
<DocListFilters docs={docs} />
|
||||||
|
</section>
|
||||||
|
|
||||||
<ChatBubble context={{}} />
|
<ChatBubble context={{}} />
|
||||||
</main>
|
|
||||||
|
{/* JSON-LD: WebSite + ItemList of recent docs (helps GEO discovery) */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: "The Disclosure Bureau",
|
||||||
|
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top",
|
||||||
|
description: locale === "en"
|
||||||
|
? `Public archive of ${counts.documents} declassified UAP/UFO documents from the US Department of War, with ${counts.events.toLocaleString()} catalogued events and ${counts.people.toLocaleString()} people.`
|
||||||
|
: `Arquivo público de ${counts.documents} documentos UAP/UFO desclassificados do Departamento de Guerra dos EUA, com ${counts.events.toLocaleString()} eventos catalogados e ${counts.people.toLocaleString()} pessoas.`,
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: `${process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top"}/search?q={search_term_string}`,
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
}) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
web/app/sightings/page.tsx
Normal file
26
web/app/sightings/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { EntityListPage } from "@/components/entity-list-page";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Avistamentos UAP/UFO — incidentes desclassificados",
|
||||||
|
description:
|
||||||
|
"Cada avistamento UAP/UFO registrado nos arquivos desclassificados do Departamento de Guerra dos EUA, " +
|
||||||
|
"do disco voador de Kenneth Arnold em 1947 às bolas de fogo verdes sobre Sandia.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SightingsPage() {
|
||||||
|
return (
|
||||||
|
<EntityListPage
|
||||||
|
entityClass="event"
|
||||||
|
folder="events"
|
||||||
|
title_en="Sightings"
|
||||||
|
title_pt="Avistamentos"
|
||||||
|
subtitle_en="Every UAP/UFO incident catalogued in the declassified record — from Kenneth Arnold's 1947 disks to the green fireballs over Sandia."
|
||||||
|
subtitle_pt="Cada incidente UAP/UFO catalogado no registro desclassificado — dos discos de Kenneth Arnold em 1947 às bolas de fogo verdes sobre Sandia."
|
||||||
|
min_mentions={1}
|
||||||
|
variant="magazine"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web/app/witnesses/page.tsx
Normal file
26
web/app/witnesses/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { EntityListPage } from "@/components/entity-list-page";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Testemunhas — quem viu, fotografou, assinou",
|
||||||
|
description:
|
||||||
|
"Pilotos, oficiais, físicos, agentes e civis citados nos documentos UAP/UFO desclassificados " +
|
||||||
|
"do Departamento de Guerra dos EUA.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WitnessesPage() {
|
||||||
|
return (
|
||||||
|
<EntityListPage
|
||||||
|
entityClass="person"
|
||||||
|
folder="people"
|
||||||
|
title_en="Witnesses"
|
||||||
|
title_pt="Testemunhas"
|
||||||
|
subtitle_en="Pilots, officers, scientists and civilians — the people named in the declassified record. Each profile links to every memo that mentions them."
|
||||||
|
subtitle_pt="Pilotos, oficiais, cientistas e civis — as pessoas nomeadas no registro desclassificado. Cada perfil liga aos memorandos onde aparecem."
|
||||||
|
min_mentions={3}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
web/components/case-library.tsx
Normal file
203
web/components/case-library.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -684,41 +684,24 @@ function ToolTrace({ t }: { t: ToolBlock }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const detective = r.detective ?? (
|
// Neutral, reader-facing card. No detective name surfaces.
|
||||||
r.kind === "hypothesis_tournament" ? "holmes" :
|
|
||||||
r.kind === "contradiction_scan" ? "dupin" :
|
|
||||||
r.kind === "red_team_review" ? "schneier" :
|
|
||||||
r.kind === "witness_analysis" ? "poirot" :
|
|
||||||
r.kind === "outlier_scan" ? "taleb" :
|
|
||||||
r.kind === "calibrate_hypothesis" ? "tetlock" :
|
|
||||||
r.kind === "case_report" ? "case-writer" : "locard"
|
|
||||||
);
|
|
||||||
const tone =
|
|
||||||
detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } :
|
|
||||||
detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } :
|
|
||||||
detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } :
|
|
||||||
detective === "poirot" ? { text: "text-[#9b5de5]", border: "border-[#9b5de5]", label: "Poirot" } :
|
|
||||||
detective === "taleb" ? { text: "text-[#ffd23f]", border: "border-[#ffd23f]", label: "Taleb" } :
|
|
||||||
detective === "tetlock" ? { text: "text-[#26d4cc]", border: "border-[#26d4cc]", label: "Tetlock" } :
|
|
||||||
detective === "case-writer" ? { text: "text-[#e0c080]", border: "border-[#e0c080]", label: "Case-Writer" } :
|
|
||||||
{ text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" };
|
|
||||||
return (
|
return (
|
||||||
<div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>
|
<div className="mt-1 ml-3 p-3 rounded border border-[#e0c080] bg-[#060a13]">
|
||||||
<div className="flex items-baseline justify-between mb-1">
|
<div className="flex items-baseline justify-between mb-1">
|
||||||
<div className={`font-mono text-[11px] font-bold ${tone.text}`}>
|
<div className="font-mono text-[11px] font-bold text-[#e0c080]">
|
||||||
🔎 {tone.label} · {r.kind}
|
🛸 Investigação em andamento
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-[#5a6678] font-mono uppercase">{r.status}</span>
|
<span className="text-[10px] text-[#5a6678] font-mono uppercase">{r.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[#9aa6b8] font-mono">
|
<div className="text-[10px] text-[#9aa6b8] font-mono">
|
||||||
job <span className="text-[#e7ecf3]">{r.job_id.slice(0, 8)}…</span>
|
caso <span className="text-[#e7ecf3]">{r.job_id.slice(0, 8)}…</span>
|
||||||
{r.eta_seconds ? <span className="ml-2">· ETA ~{r.eta_seconds}s</span> : null}
|
{r.eta_seconds ? <span className="ml-2">· ETA ~{r.eta_seconds}s</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{r.status_url && (
|
{r.status_url && (
|
||||||
<Link
|
<Link
|
||||||
href={r.status_url}
|
href={r.status_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className={`mt-2 inline-flex items-center gap-1 text-[11px] font-mono ${tone.text} hover:underline`}
|
className="mt-2 inline-flex items-center gap-1 text-[11px] font-mono text-[#e0c080] hover:underline"
|
||||||
>
|
>
|
||||||
acompanhar a investigação <ArrowUpRight size={11} />
|
acompanhar a investigação <ArrowUpRight size={11} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,234 +1,74 @@
|
||||||
/**
|
/**
|
||||||
* DocBureauPanel — surfaces Investigation Bureau artefacts that touch a
|
* DocCasePanel — links from a document page to any narrative case files
|
||||||
* specific doc_id on the /d/[docId] page.
|
* that reference it.
|
||||||
*
|
*
|
||||||
* Server component. Resolves:
|
* No detective surfacing. No evidence/hypothesis/contradiction IDs. Just
|
||||||
* - Evidence whose source_page_id starts with doc_id/p (FK via the
|
* the cases that drew on this document, presented as story cards.
|
||||||
* chunks table would be cleaner but the LIKE prefix is fine and
|
|
||||||
* index-friendly).
|
|
||||||
* - Hypotheses whose evidence_refs contain any E-NNNN from this doc.
|
|
||||||
* - Contradictions whose chunks array contains an item with this doc_id.
|
|
||||||
* - Outliers (gaps) whose scope.doc_id == this doc_id.
|
|
||||||
* - Case reports whose body markdown references the doc_id.
|
|
||||||
*
|
|
||||||
* Renders nothing if all five lists are empty (doc untouched by bureau).
|
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { pgQuery } from "@/lib/retrieval/db";
|
|
||||||
|
|
||||||
interface EvRow { evidence_id: string; grade: string; confidence_band: string | null; source_page_id: string }
|
interface ReportRow { slug: string; topic: string; topic_pt_br: string | null; opening: string }
|
||||||
interface HypRow { hypothesis_id: string; position: string; position_pt_br: string | null; confidence_band: string | null; posterior: number | string | null }
|
|
||||||
interface CtrRow { contradiction_id: string; topic: string; topic_pt_br: string | null; resolution_status: string }
|
|
||||||
interface GapRow { gap_id: string; description: string; description_pt_br: string | null; scope: unknown; status: string }
|
|
||||||
interface ReportRow { slug: string; topic: string }
|
|
||||||
|
|
||||||
const BAND_TONE: Record<string, string> = {
|
|
||||||
high: "text-[#06d6a0]", medium: "text-[#3fde6a]",
|
|
||||||
low: "text-[#ffa500]", speculation: "text-[#ff6ec7]",
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRADE_TONE: Record<string, string> = {
|
|
||||||
A: "text-[#06d6a0] border-[#06d6a0]",
|
|
||||||
B: "text-[#3fde6a] border-[#3fde6a]",
|
|
||||||
C: "text-[#ffa500] border-[#ffa500]",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function DocBureauPanel({ docId }: { docId: string }) {
|
export async function DocBureauPanel({ docId }: { docId: string }) {
|
||||||
// Evidence on this doc (cheap).
|
|
||||||
const ev: EvRow[] = await pgQuery<EvRow>(
|
|
||||||
`SELECT evidence_id, grade, confidence_band, source_page_id
|
|
||||||
FROM public.evidence
|
|
||||||
WHERE source_page_id LIKE $1 || '/%'
|
|
||||||
ORDER BY evidence_id LIMIT 20`,
|
|
||||||
[docId],
|
|
||||||
).catch(() => []);
|
|
||||||
|
|
||||||
// Hypotheses citing any of the evidence_ids above.
|
|
||||||
const evIds = ev.map((e) => e.evidence_id);
|
|
||||||
const hyp: HypRow[] = evIds.length > 0
|
|
||||||
? await pgQuery<HypRow>(
|
|
||||||
`SELECT hypothesis_id, position, position_pt_br, confidence_band, posterior
|
|
||||||
FROM public.hypotheses
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1 FROM jsonb_array_elements(evidence_refs) er
|
|
||||||
WHERE er->>'evidence_id' = ANY($1::text[])
|
|
||||||
)
|
|
||||||
ORDER BY hypothesis_id LIMIT 12`,
|
|
||||||
[evIds],
|
|
||||||
).catch(() => [])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Contradictions whose chunks[] has any chunk with this doc_id.
|
|
||||||
const ctr: CtrRow[] = await pgQuery<CtrRow>(
|
|
||||||
`SELECT contradiction_id, topic, topic_pt_br, resolution_status
|
|
||||||
FROM public.contradictions
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1 FROM jsonb_array_elements(chunks) c
|
|
||||||
WHERE c->>'doc_id' = $1
|
|
||||||
)
|
|
||||||
ORDER BY contradiction_id LIMIT 8`,
|
|
||||||
[docId],
|
|
||||||
).catch(() => []);
|
|
||||||
|
|
||||||
// Outliers (gaps with scope.doc_id matching).
|
|
||||||
const gap: GapRow[] = await pgQuery<GapRow>(
|
|
||||||
`SELECT gap_id, description, description_pt_br, scope, status
|
|
||||||
FROM public.gaps
|
|
||||||
WHERE scope->>'doc_id' = $1
|
|
||||||
ORDER BY gap_id LIMIT 8`,
|
|
||||||
[docId],
|
|
||||||
).catch(() => []);
|
|
||||||
|
|
||||||
// Case reports referencing this doc_id in the body. We don't store body
|
|
||||||
// in pg; instead we scan the reports/ dir filesystem-side for now.
|
|
||||||
// (Lightweight — bureau reports are O(10) files.)
|
|
||||||
let reports: ReportRow[] = [];
|
|
||||||
try {
|
|
||||||
const { readdir, readFile } = await import("node:fs/promises");
|
const { readdir, readFile } = await import("node:fs/promises");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
const dir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports");
|
const dir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports");
|
||||||
const files = await readdir(dir).catch(() => [] as string[]);
|
|
||||||
const found: ReportRow[] = [];
|
|
||||||
for (const f of files.filter((x) => x.endsWith(".md"))) {
|
|
||||||
const md = await readFile(path.join(dir, f), "utf-8");
|
|
||||||
if (md.includes(docId)) {
|
|
||||||
const topicMatch = md.match(/topic:\s*"([^"]+)"/);
|
|
||||||
found.push({ slug: f.replace(/\.md$/, ""), topic: topicMatch?.[1] ?? f });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reports = found;
|
|
||||||
} catch { /* fine */ }
|
|
||||||
|
|
||||||
const total = ev.length + hyp.length + ctr.length + gap.length + reports.length;
|
let reports: ReportRow[] = [];
|
||||||
if (total === 0) {
|
try {
|
||||||
return (
|
const files = await readdir(dir);
|
||||||
<section className="mb-6 rounded-lg border border-dashed border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-4">
|
for (const f of files) {
|
||||||
<div className="flex items-baseline justify-between flex-wrap gap-2">
|
if (!f.endsWith(".md")) continue;
|
||||||
<div className="text-[10px] font-mono text-[#5a6678] uppercase tracking-wider">
|
const md = await readFile(path.join(dir, f), "utf-8");
|
||||||
// Investigation Bureau — untouched
|
if (!md.includes(docId)) continue;
|
||||||
</div>
|
const topicMatch = md.match(/topic:\s*"([^"]+)"/);
|
||||||
<Link
|
const topicPtMatch = md.match(/topic_pt_br:\s*"([^"]+)"/);
|
||||||
href={`/?launch=${encodeURIComponent(docId)}`}
|
const bodyMatch = md.match(/^---[\s\S]+?\n---\n([\s\S]+)$/);
|
||||||
className="text-[10px] font-mono text-[#e0c080] hover:underline"
|
const body = bodyMatch?.[1] ?? "";
|
||||||
>
|
// First non-heading paragraph as opening hook.
|
||||||
launch an investigation →
|
const opening = body
|
||||||
</Link>
|
.split("\n")
|
||||||
</div>
|
.find((l) => l.trim().length > 0 && !l.startsWith("#") && !l.startsWith(">") && !l.startsWith("|") && !l.startsWith("---"))
|
||||||
<p className="text-[11px] text-[#5a6678] font-mono mt-2">
|
?.slice(0, 240) ?? "";
|
||||||
No detective has produced an artefact for this document yet.
|
reports.push({
|
||||||
</p>
|
slug: f.replace(/\.md$/, ""),
|
||||||
</section>
|
topic: topicMatch?.[1] ?? f,
|
||||||
);
|
topic_pt_br: topicPtMatch?.[1] ?? null,
|
||||||
|
opening,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* fine — no reports yet */ }
|
||||||
|
|
||||||
|
if (reports.length === 0) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-6 rounded-lg border border-[rgba(224,192,128,0.18)] bg-gradient-to-br from-[rgba(224,192,128,0.04)] to-transparent p-4">
|
<section className="mb-6 rounded-lg border border-[rgba(224,192,128,0.18)] bg-gradient-to-br from-[rgba(224,192,128,0.04)] to-transparent p-4">
|
||||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
<div className="text-[10px] font-mono text-[#e0c080] uppercase tracking-wider mb-3">
|
||||||
<div className="text-[10px] font-mono text-[#e0c080] uppercase tracking-wider">
|
// Casos narrados que citam este documento
|
||||||
// Investigation Bureau · {total} artefact{total === 1 ? "" : "s"} touch this doc
|
|
||||||
</div>
|
</div>
|
||||||
<Link href="/bureau" className="text-[10px] font-mono text-[#e0c080] hover:underline">
|
|
||||||
full bureau →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-3">
|
<div className="grid md:grid-cols-2 gap-3">
|
||||||
{hyp.length > 0 && (
|
|
||||||
<Panel title="Hypotheses" color="text-[#7fdbff]" border="border-[rgba(127,219,255,0.18)]">
|
|
||||||
{hyp.map((h) => {
|
|
||||||
const tone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8]";
|
|
||||||
const post = h.posterior !== null ? Number(h.posterior) : null;
|
|
||||||
return (
|
|
||||||
<Link key={h.hypothesis_id} href={`/h/${h.hypothesis_id}`}
|
|
||||||
className="block py-1.5 border-t border-[rgba(127,219,255,0.08)] first:border-t-0 hover:bg-[rgba(127,219,255,0.04)]">
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{h.hypothesis_id}</span>
|
|
||||||
<span className={`text-[10px] font-mono ${tone}`}>
|
|
||||||
{h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position_pt_br || h.position}</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.length > 0 && (
|
|
||||||
<Panel title="Evidence" color="text-[#06d6a0]" border="border-[rgba(6,214,160,0.18)]">
|
|
||||||
{ev.map((e) => {
|
|
||||||
const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
|
|
||||||
return (
|
|
||||||
<div key={e.evidence_id} className="py-1.5 border-t border-[rgba(6,214,160,0.08)] first:border-t-0">
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{e.evidence_id}</span>
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>
|
|
||||||
Grade {e.grade}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] font-mono text-[#5a6678] mt-0.5">{e.source_page_id}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctr.length > 0 && (
|
|
||||||
<Panel title="Contradictions" color="text-[#ff8a4d]" border="border-[rgba(255,138,77,0.18)]">
|
|
||||||
{ctr.map((c) => (
|
|
||||||
<div key={c.contradiction_id} className="py-1.5 border-t border-[rgba(255,138,77,0.08)] first:border-t-0">
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
|
|
||||||
<span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic_pt_br || c.topic}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{gap.length > 0 && (
|
|
||||||
<Panel title="Outliers" color="text-[#ffd23f]" border="border-[rgba(255,210,63,0.25)]">
|
|
||||||
{gap.map((g) => {
|
|
||||||
const s = (g.scope ?? {}) as Record<string, unknown>;
|
|
||||||
const title = (s.title_pt_br as string) || (s.title as string)
|
|
||||||
|| g.description_pt_br || g.description;
|
|
||||||
return (
|
|
||||||
<div key={g.gap_id} className="py-1.5 border-t border-[rgba(255,210,63,0.08)] first:border-t-0">
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-[#5a6678]">{g.gap_id}</span>
|
|
||||||
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{title}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reports.length > 0 && (
|
|
||||||
<Panel title="Case reports referencing this doc" color="text-[#e0c080]" border="border-[rgba(224,192,128,0.25)]">
|
|
||||||
{reports.map((r) => (
|
{reports.map((r) => (
|
||||||
<Link key={r.slug} href={`/c/${r.slug}`}
|
<Link
|
||||||
className="block py-1.5 border-t border-[rgba(224,192,128,0.08)] first:border-t-0 hover:bg-[rgba(224,192,128,0.04)]">
|
key={r.slug}
|
||||||
<div className="text-[10px] font-mono text-[#5a6678]">/c/{r.slug}</div>
|
href={`/c/${r.slug}`}
|
||||||
<div className="text-[12px] text-[#e0c080] leading-snug mt-0.5 font-medium">{r.topic}</div>
|
className="block rounded border border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(224,192,128,0.04)]"
|
||||||
|
>
|
||||||
|
<div className="text-[14px] text-[#e7ecf3] font-medium mb-1">
|
||||||
|
{r.topic_pt_br || r.topic}
|
||||||
|
</div>
|
||||||
|
{r.opening && (
|
||||||
|
<div className="text-[11px] text-[#9aa6b8] leading-relaxed line-clamp-3">
|
||||||
|
{r.opening}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 text-[10px] font-mono text-[#5a6678]">
|
||||||
|
/c/{r.slug} →
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel({ title, color, border, children }: {
|
|
||||||
title: string; color: string; border: string; children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={`rounded border ${border} bg-[#0d1220] p-3`}>
|
|
||||||
<div className={`text-[10px] font-mono uppercase tracking-wider ${color} mb-1`}>{title}</div>
|
|
||||||
<div>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
182
web/components/entity-list-page.tsx
Normal file
182
web/components/entity-list-page.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* EntityListPage — shared shell used by /sightings, /witnesses, /objects,
|
||||||
|
* /locations, /operations.
|
||||||
|
*
|
||||||
|
* Pulls entities of a given class, sorted by total_mentions, paginated.
|
||||||
|
* Renders a magazine-style index page with a hero header + searchable
|
||||||
|
* card grid. No detective branding.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { pgQuery } from "@/lib/retrieval/db";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { BureauNav } from "@/components/bureau-nav";
|
||||||
|
import { getLocale } from "@/components/locale-toggle";
|
||||||
|
|
||||||
|
interface EntityRow {
|
||||||
|
entity_class: string;
|
||||||
|
entity_id: string;
|
||||||
|
canonical_name: string;
|
||||||
|
aliases: string[] | null;
|
||||||
|
total_mentions: number;
|
||||||
|
documents_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityListPageProps {
|
||||||
|
entityClass: "event" | "person" | "uap_object" | "location" | "organization";
|
||||||
|
/** URL folder under /e/ — kebab-case plural. */
|
||||||
|
folder: "events" | "people" | "uap-objects" | "locations" | "organizations";
|
||||||
|
title_en: string;
|
||||||
|
title_pt: string;
|
||||||
|
subtitle_en: string;
|
||||||
|
subtitle_pt: string;
|
||||||
|
/** Minimum total_mentions to surface — filters out doc-scoped noise. */
|
||||||
|
min_mentions?: number;
|
||||||
|
/** Card variant. "magazine" = larger cards with year/icon; "compact" = tabular list. */
|
||||||
|
variant?: "magazine" | "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEventId(id: string): { year: number | null } {
|
||||||
|
const m = id.match(/^EV-(\d{4})-/i);
|
||||||
|
return { year: m ? parseInt(m[1], 10) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function EntityListPage(props: EntityListPageProps) {
|
||||||
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
||||||
|
const rows = await pgQuery<EntityRow>(
|
||||||
|
`SELECT entity_class, entity_id, canonical_name, aliases,
|
||||||
|
total_mentions, documents_count
|
||||||
|
FROM public.entities
|
||||||
|
WHERE entity_class = $1
|
||||||
|
AND total_mentions >= $2
|
||||||
|
AND canonical_name !~ '^(unspecified|unknown|n/a|—|UNKNOWN)$'
|
||||||
|
ORDER BY total_mentions DESC, canonical_name ASC
|
||||||
|
LIMIT 200`,
|
||||||
|
[props.entityClass, props.min_mentions ?? 1],
|
||||||
|
).catch(() => [] as EntityRow[]);
|
||||||
|
|
||||||
|
const title = locale === "en" ? props.title_en : props.title_pt;
|
||||||
|
const subtitle = locale === "en" ? props.subtitle_en : props.subtitle_pt;
|
||||||
|
const variant = props.variant ?? "magazine";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<SiteHeader locale={locale} />
|
||||||
|
<BureauNav crumbs={[{ label: title.toLowerCase() }]} />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl px-4 md:px-8 py-10 md:py-14">
|
||||||
|
<header className="mb-10 md:mb-14">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#5a6678] mb-3">
|
||||||
|
{locale === "en" ? "// The archive" : "// O arquivo"}
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-4xl md:text-6xl font-semibold text-[#e7ecf3] leading-tight mb-3">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-[#9aa6b8] max-w-2xl">{subtitle}</p>
|
||||||
|
<p className="text-[11px] font-mono text-[#5a6678] mt-3">
|
||||||
|
{rows.length} {locale === "en" ? "entries" : "entradas"}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-8 text-center">
|
||||||
|
<p className="text-[12px] font-mono text-[#9aa6b8]">
|
||||||
|
{locale === "en" ? "No entries yet — the corpus is still being indexed." : "Sem entradas ainda — o corpus está sendo indexado."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : variant === "magazine" ? (
|
||||||
|
<MagazineGrid rows={rows} folder={props.folder} entityClass={props.entityClass} locale={locale} />
|
||||||
|
) : (
|
||||||
|
<CompactGrid rows={rows} folder={props.folder} locale={locale} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON-LD ItemList for GEO */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
name: title,
|
||||||
|
description: subtitle,
|
||||||
|
numberOfItems: rows.length,
|
||||||
|
mainEntity: {
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: rows.slice(0, 50).map((r, i) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: i + 1,
|
||||||
|
name: r.canonical_name,
|
||||||
|
url: `${process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top"}/e/${props.folder}/${r.entity_id}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MagazineGrid({
|
||||||
|
rows, folder, entityClass, locale,
|
||||||
|
}: { rows: EntityRow[]; folder: string; entityClass: string; locale: "pt-br" | "en" }) {
|
||||||
|
return (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const year = entityClass === "event" ? parseEventId(r.entity_id).year : null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={r.entity_id}
|
||||||
|
href={`/e/${folder}/${r.entity_id}`}
|
||||||
|
className="group block rounded-xl border border-[rgba(127,219,255,0.15)] bg-[#0d1220] p-5 hover:border-[#7fdbff]/50 hover:bg-[#10162a] transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<span className="font-mono text-[10px] tracking-[0.14em] uppercase text-[#7fdbff]/70">
|
||||||
|
{year ?? `#${(i + 1).toString().padStart(3, "0")}`}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-[#5a6678] tabular-nums">
|
||||||
|
{r.total_mentions.toLocaleString("pt-BR")} {locale === "en" ? "mentions" : "menções"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-lg md:text-xl text-[#e7ecf3] group-hover:text-[#7fdbff] transition-colors leading-snug">
|
||||||
|
{r.canonical_name}
|
||||||
|
</h3>
|
||||||
|
{r.aliases && r.aliases.length > 0 && (
|
||||||
|
<div className="mt-2 text-[11px] text-[#5a6678] line-clamp-1">
|
||||||
|
{r.aliases.slice(0, 3).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactGrid({
|
||||||
|
rows, folder, locale,
|
||||||
|
}: { rows: EntityRow[]; folder: string; locale: "pt-br" | "en" }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[rgba(127,219,255,0.10)] bg-[#0d1220] overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="text-[10px] font-mono uppercase tracking-wider text-[#5a6678] border-b border-[rgba(127,219,255,0.10)]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">{locale === "en" ? "name" : "nome"}</th>
|
||||||
|
<th className="text-right px-4 py-3">{locale === "en" ? "mentions" : "menções"}</th>
|
||||||
|
<th className="text-right px-4 py-3 hidden sm:table-cell">{locale === "en" ? "documents" : "docs"}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.entity_id} className="border-t border-[rgba(127,219,255,0.05)] hover:bg-[rgba(127,219,255,0.03)]">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link href={`/e/${folder}/${r.entity_id}`} className="text-[#e7ecf3] hover:text-[#7fdbff]">
|
||||||
|
{r.canonical_name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-[#9aa6b8] tabular-nums">{r.total_mentions.toLocaleString("pt-BR")}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-[#5a6678] tabular-nums hidden sm:table-cell">{r.documents_count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
web/components/featured-case.tsx
Normal file
140
web/components/featured-case.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
interface FeaturedCaseData {
|
||||||
|
slug: string;
|
||||||
|
topic: string;
|
||||||
|
topic_pt_br: string | null;
|
||||||
|
opening: string;
|
||||||
|
hero_doc_id: string | null;
|
||||||
|
hero_page: number | 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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
items.push({
|
||||||
|
slug: f.replace(/\.md$/, ""),
|
||||||
|
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,
|
||||||
|
mtimeMs: st.mtimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const heroImg = 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;
|
||||||
|
|
||||||
|
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-[3/4] 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
web/components/greatest-hits.tsx
Normal file
87
web/components/greatest-hits.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* GreatestHits — curated list of the most-cited events in the corpus.
|
||||||
|
*
|
||||||
|
* Shows the top N events by `total_mentions` from public.entities, which
|
||||||
|
* surfaces the historical anchors: Kenneth Arnold 1947, Mantell 1948, etc.
|
||||||
|
* Each card is an editorial teaser linking to the event entity page.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { pgQuery } from "@/lib/retrieval/db";
|
||||||
|
|
||||||
|
interface EventRow {
|
||||||
|
entity_id: string;
|
||||||
|
canonical_name: string;
|
||||||
|
total_mentions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse "EV-1947-06-24-kenneth-arnold-sighting" → { year, slug }.
|
||||||
|
* Returns null when the entity_id doesn't follow the canonical pattern.
|
||||||
|
*/
|
||||||
|
function parseEventId(id: string): { year: number | null; slug: string } {
|
||||||
|
const m = id.match(/^EV-(\d{4})-(?:\d{2}|XX)-(?:\d{2}|XX)-(.+)$/i);
|
||||||
|
if (!m) return { year: null, slug: id };
|
||||||
|
return { year: parseInt(m[1], 10), slug: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GreatestHits({ locale, limit = 9 }: { locale: "pt-br" | "en"; limit?: number }) {
|
||||||
|
const rows = await pgQuery<EventRow>(
|
||||||
|
`SELECT entity_id, canonical_name, total_mentions
|
||||||
|
FROM public.entities
|
||||||
|
WHERE entity_class = 'event'
|
||||||
|
AND total_mentions >= 2
|
||||||
|
AND canonical_name NOT ILIKE '%unspecified%'
|
||||||
|
AND canonical_name NOT ILIKE '%unknown%'
|
||||||
|
ORDER BY total_mentions DESC, entity_id ASC
|
||||||
|
LIMIT $1`,
|
||||||
|
[limit],
|
||||||
|
).catch(() => [] as EventRow[]);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-7xl px-4 md:px-8 py-12 md:py-16">
|
||||||
|
<div className="flex items-end justify-between mb-6 flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#5a6678] mb-2">
|
||||||
|
{locale === "en" ? "// The historical record" : "// O registro histórico"}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl md:text-3xl text-[#e7ecf3]">
|
||||||
|
{locale === "en" ? "The incidents the archive keeps returning to" : "Os incidentes que o arquivo não esquece"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/sightings" className="text-[12px] font-mono text-[#e0c080] hover:underline">
|
||||||
|
{locale === "en" ? "all sightings →" : "todos os avistamentos →"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const { year } = parseEventId(r.entity_id);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={r.entity_id}
|
||||||
|
href={`/e/events/${r.entity_id}`}
|
||||||
|
className="group block rounded-xl border border-[rgba(127,219,255,0.15)] bg-[#0d1220] p-5 hover:border-[#7fdbff]/50 hover:bg-[#10162a] transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<span className="font-mono text-[10px] tracking-[0.14em] uppercase text-[#7fdbff]/70">
|
||||||
|
{year ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-[#5a6678] tabular-nums">
|
||||||
|
{r.total_mentions} {locale === "en" ? "mentions" : "menções"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-lg md:text-xl text-[#e7ecf3] group-hover:text-[#7fdbff] transition-colors leading-snug">
|
||||||
|
{r.canonical_name}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3 text-[11px] font-mono text-[#5a6678]">
|
||||||
|
#{(i + 1).toString().padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
web/components/hero-banner.tsx
Normal file
120
web/components/hero-banner.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* HeroBanner — homepage opener.
|
||||||
|
*
|
||||||
|
* Full-bleed banner that establishes the brand promise. Reader sees:
|
||||||
|
* - The mission line in display serif
|
||||||
|
* - A subhead that names what this archive contains
|
||||||
|
* - A live stats row showing real corpus counts
|
||||||
|
*
|
||||||
|
* Uses a declassified document page as background art (the green-fireball
|
||||||
|
* Sandia file is a good choice — period typewriter look). Falls back to
|
||||||
|
* a CSS gradient when the asset is unavailable.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
documents: number;
|
||||||
|
events: number;
|
||||||
|
people: number;
|
||||||
|
uap_objects: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroBanner({ locale, stats, heroDocId, heroPage }: {
|
||||||
|
locale: "pt-br" | "en";
|
||||||
|
stats: Stats;
|
||||||
|
heroDocId?: string;
|
||||||
|
heroPage?: number;
|
||||||
|
}) {
|
||||||
|
const heroImg = heroDocId && heroPage
|
||||||
|
? `/api/static/processing/png/${heroDocId}/p${String(heroPage).padStart(3, "0")}.png`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden border-b border-[rgba(224,192,128,0.15)]">
|
||||||
|
{/* Background art layer */}
|
||||||
|
{heroImg && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={heroImg}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-[0.18] mix-blend-luminosity"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Gradient overlay so text always reads */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#0a0e1a] via-[#0a0e1a]/85 to-[#0a0e1a]/95" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(224,192,128,0.10),transparent_60%)]" />
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-7xl px-4 md:px-8 py-16 md:py-24">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-[rgba(224,192,128,0.30)] bg-[rgba(224,192,128,0.06)] mb-6">
|
||||||
|
<span className="size-1.5 rounded-full bg-[#e0c080] animate-pulse" />
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080]">
|
||||||
|
{locale === "en" ? "Declassified archive · live" : "Arquivo desclassificado · ao vivo"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-display text-4xl md:text-6xl lg:text-7xl font-semibold text-[#e7ecf3] leading-[1.05] tracking-tight mb-6">
|
||||||
|
{locale === "en" ? (
|
||||||
|
<>
|
||||||
|
The files the government<br />
|
||||||
|
<span className="text-[#e0c080]">didn’t want you to read.</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Os arquivos que o governo<br />
|
||||||
|
<span className="text-[#e0c080]">não queria que você lesse.</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-[#cbd2dd] leading-relaxed mb-8 max-w-2xl">
|
||||||
|
{locale === "en"
|
||||||
|
? <>
|
||||||
|
<strong className="text-[#e7ecf3]">122 declassified UAP/UFO documents</strong> from the
|
||||||
|
US Department of War. Pilots, officers and physicists describe what they
|
||||||
|
saw — read from primary sources, with every quote linked to its page.
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
<strong className="text-[#e7ecf3]">122 documentos UAP/UFO desclassificados</strong> do
|
||||||
|
Departamento de Guerra dos EUA. Pilotos, oficiais e físicos descrevem o
|
||||||
|
que viram — lido das fontes primárias, com cada citação ligada à sua página.
|
||||||
|
</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats row — anchors trust and gives GEO-friendly factual numbers */}
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-2">
|
||||||
|
<Stat label={locale === "en" ? "Documents" : "Documentos"} value={stats.documents.toLocaleString("pt-BR")} />
|
||||||
|
<Stat label={locale === "en" ? "Events" : "Eventos"} value={stats.events.toLocaleString("pt-BR")} />
|
||||||
|
<Stat label={locale === "en" ? "Witnesses" : "Testemunhas"} value={stats.people.toLocaleString("pt-BR")} />
|
||||||
|
<Stat label={locale === "en" ? "Craft catalogued" : "Objetos catalogados"} value={stats.uap_objects.toLocaleString("pt-BR")} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center gap-3 flex-wrap">
|
||||||
|
<Link
|
||||||
|
href="/bureau"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#e0c080] text-[#0a0e1a] font-mono text-[13px] font-semibold hover:bg-[#f0d090] transition-colors"
|
||||||
|
>
|
||||||
|
{locale === "en" ? "Read the case files →" : "Ler os casos →"}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/sightings"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[#e0c080]/40 text-[#e0c080] font-mono text-[13px] hover:bg-[rgba(224,192,128,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
{locale === "en" ? "Browse sightings" : "Avistamentos"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-[10px] font-mono uppercase tracking-[0.14em] text-[#5a6678]">{label}</dt>
|
||||||
|
<dd className="font-display text-3xl md:text-4xl font-semibold text-[#e7ecf3] mt-1 tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
web/components/portal-grid.tsx
Normal file
122
web/components/portal-grid.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* PortalGrid — magazine-style 6-tile navigation under the hero.
|
||||||
|
*
|
||||||
|
* Each tile is a thematic doorway into a slice of the archive:
|
||||||
|
* sightings (events), witnesses (people), craft (uap_objects),
|
||||||
|
* locations, programs (operations), documents. Counts come from
|
||||||
|
* public.entities counters loaded by the homepage server component.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export interface PortalCounts {
|
||||||
|
events: number;
|
||||||
|
people: number;
|
||||||
|
uap_objects: number;
|
||||||
|
locations: number;
|
||||||
|
operations: number;
|
||||||
|
documents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Portal {
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
title_en: string;
|
||||||
|
title_pt: string;
|
||||||
|
blurb_en: string;
|
||||||
|
blurb_pt: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalGrid({ counts, locale }: { counts: PortalCounts; locale: "pt-br" | "en" }) {
|
||||||
|
const portals: Portal[] = [
|
||||||
|
{
|
||||||
|
href: "/sightings",
|
||||||
|
icon: "🛸",
|
||||||
|
title_en: "Sightings",
|
||||||
|
title_pt: "Avistamentos",
|
||||||
|
blurb_en: "Every recorded incident in the archive, from Kenneth Arnold's 1947 disks to the green fireballs over Sandia.",
|
||||||
|
blurb_pt: "Cada incidente registrado no arquivo, dos discos de Kenneth Arnold em 1947 às bolas de fogo verdes sobre Sandia.",
|
||||||
|
count: counts.events,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/witnesses",
|
||||||
|
icon: "✍️",
|
||||||
|
title_en: "Witnesses",
|
||||||
|
title_pt: "Testemunhas",
|
||||||
|
blurb_en: "Pilots, officers, scientists — the people who saw, photographed, or signed the memos.",
|
||||||
|
blurb_pt: "Pilotos, oficiais, cientistas — quem viu, fotografou ou assinou os memorandos.",
|
||||||
|
count: counts.people,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/objects",
|
||||||
|
icon: "📷",
|
||||||
|
title_en: "Craft",
|
||||||
|
title_pt: "Objetos UAP",
|
||||||
|
blurb_en: "Catalogued by shape and behaviour: discs, cigars, spheres, triangles, tic-tacs.",
|
||||||
|
blurb_pt: "Catalogados por forma e comportamento: discos, charutos, esferas, triângulos, tic-tacs.",
|
||||||
|
count: counts.uap_objects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/locations",
|
||||||
|
icon: "🗺",
|
||||||
|
title_en: "Hot spots",
|
||||||
|
title_pt: "Hot spots",
|
||||||
|
blurb_en: "Where the sky lit up. Sandia, Roswell, Rendlesham, Phoenix, Nimitz waters.",
|
||||||
|
blurb_pt: "Onde o céu acendeu. Sandia, Roswell, Rendlesham, Phoenix, águas do Nimitz.",
|
||||||
|
count: counts.locations,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/operations",
|
||||||
|
icon: "🏛",
|
||||||
|
title_en: "Programs",
|
||||||
|
title_pt: "Programas secretos",
|
||||||
|
blurb_en: "Project Blue Book, the Robertson Panel, AATIP — the official machinery of disclosure.",
|
||||||
|
blurb_pt: "Project Blue Book, Robertson Panel, AATIP — a máquina oficial da divulgação.",
|
||||||
|
count: counts.operations,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/documents",
|
||||||
|
icon: "📂",
|
||||||
|
title_en: "Documents",
|
||||||
|
title_pt: "Documentos",
|
||||||
|
blurb_en: "The primary record — every memo, telegram and report indexed and searchable.",
|
||||||
|
blurb_pt: "O registro primário — cada memorando, telegrama e relatório indexado e buscável.",
|
||||||
|
count: counts.documents,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-[#0c111e] border-y border-[rgba(224,192,128,0.10)]">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 md:px-8 py-10 md:py-14">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#5a6678] mb-4">
|
||||||
|
{locale === "en" ? "// Enter the archive" : "// Entre no arquivo"}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
|
{portals.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.href}
|
||||||
|
href={p.href}
|
||||||
|
className="group relative rounded-xl border border-[rgba(224,192,128,0.12)] bg-[#0d1220] p-5 md:p-6 hover:border-[#e0c080]/50 hover:bg-[#10162a] transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
|
<span className="text-2xl">{p.icon}</span>
|
||||||
|
<span className="font-mono text-[11px] text-[#5a6678] tabular-nums">
|
||||||
|
{p.count.toLocaleString("pt-BR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-xl md:text-2xl text-[#e7ecf3] group-hover:text-[#e0c080] transition-colors mb-2">
|
||||||
|
{locale === "en" ? p.title_en : p.title_pt}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[13px] text-[#9aa6b8] leading-relaxed">
|
||||||
|
{locale === "en" ? p.blurb_en : p.blurb_pt}
|
||||||
|
</p>
|
||||||
|
<div className="absolute bottom-5 right-5 text-[#5a6678] group-hover:text-[#e0c080] group-hover:translate-x-1 transition-all">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,28 +23,27 @@ interface DetectiveOption {
|
||||||
tone: string;
|
tone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single reader-facing option: assemble a case-file narrative on any topic.
|
||||||
|
// The pipeline (multiple internal mind-clones in parallel) is hidden.
|
||||||
const OPTIONS: DetectiveOption[] = [
|
const OPTIONS: DetectiveOption[] = [
|
||||||
{ slug: "holmes", label: "Holmes — hypothesis tournament", kind: "hypothesis_tournament", inputLabel: "Question (one sentence, declarative)", placeholder: "Were the green fireballs natural or unidentified?", field: "question", tone: "text-[#7fdbff] border-[#7fdbff]" },
|
{
|
||||||
{ slug: "dupin", label: "Dupin — contradiction scan", kind: "contradiction_scan", inputLabel: "Topic (short noun-phrase)", placeholder: "color of the green fireballs", field: "topic", tone: "text-[#ff8a4d] border-[#ff8a4d]" },
|
slug: "case",
|
||||||
{ slug: "taleb", label: "Taleb — outlier hunt", kind: "outlier_scan", inputLabel: "Topic", placeholder: "anomalous frequency of UAP at nuclear sites", field: "topic", tone: "text-[#ffd23f] border-[#ffd23f]" },
|
label: "Investigar um caso",
|
||||||
{ slug: "schneier", label: "Schneier — red-team a hypothesis", kind: "red_team_review", inputLabel: "Hypothesis ID (H-NNNN)", placeholder: "H-0003", field: "hypothesis_id", tone: "text-[#ff3344] border-[#ff3344]" },
|
kind: "case_report",
|
||||||
{ slug: "tetlock", label: "Tetlock — recalibrate a hypothesis", kind: "calibrate_hypothesis", inputLabel: "Hypothesis ID (H-NNNN)", placeholder: "H-0003", field: "hypothesis_id", tone: "text-[#26d4cc] border-[#26d4cc]" },
|
inputLabel: "Tópico ou pergunta",
|
||||||
{ slug: "poirot", label: "Poirot — witness analysis", kind: "witness_analysis", inputLabel: "Person ID (kebab-case)", placeholder: "donald-keyhoe", field: "person_id", tone: "text-[#9b5de5] border-[#9b5de5]" },
|
placeholder: "as bolas de fogo verdes sobre Sandia",
|
||||||
{ slug: "case-writer", label: "Case-Writer — assemble narrative", kind: "case_report", inputLabel: "Topic to assemble", placeholder: "green fireballs", field: "topic", tone: "text-[#e0c080] border-[#e0c080]" },
|
field: "topic",
|
||||||
|
tone: "text-[#e0c080] border-[#e0c080]",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function QuickLaunch() {
|
export function QuickLaunch() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [opt, setOpt] = useState<DetectiveOption>(OPTIONS[0]);
|
const opt = OPTIONS[0];
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
function pickDetective(slug: string) {
|
|
||||||
const next = OPTIONS.find((o) => o.slug === slug);
|
|
||||||
if (next) { setOpt(next); setValue(""); setError(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!value.trim()) return;
|
if (!value.trim()) return;
|
||||||
|
|
@ -74,35 +73,26 @@ export function QuickLaunch() {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
className="rounded-lg border border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-3 mb-5"
|
className="rounded-lg border border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-3 mb-5"
|
||||||
>
|
>
|
||||||
<div className="text-[10px] font-mono text-[#5a6678] uppercase tracking-wider mb-2">
|
<div className="text-[10px] font-mono text-[#e0c080] uppercase tracking-wider mb-2">
|
||||||
⚡ Launch an investigation
|
🛸 Investigar um caso
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-[260px_1fr_auto] gap-2">
|
<div className="grid md:grid-cols-[1fr_auto] gap-2">
|
||||||
<select
|
|
||||||
value={opt.slug}
|
|
||||||
onChange={(e) => pickDetective(e.target.value)}
|
|
||||||
className={`bg-[#060a13] border ${opt.tone} rounded px-2 py-2 text-[12px] font-mono`}
|
|
||||||
>
|
|
||||||
{OPTIONS.map((o) => (
|
|
||||||
<option key={o.slug} value={o.slug}>{o.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder={opt.placeholder}
|
placeholder={opt.placeholder}
|
||||||
aria-label={opt.inputLabel}
|
aria-label={opt.inputLabel}
|
||||||
className="bg-[#060a13] border border-[rgba(127,219,255,0.18)] rounded px-3 py-2 text-[12px] text-[#e7ecf3] placeholder:text-[#5a6678] font-mono"
|
className="bg-[#060a13] border border-[rgba(224,192,128,0.18)] rounded px-3 py-2 text-[13px] text-[#e7ecf3] placeholder:text-[#5a6678] font-sans"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending || !value.trim()}
|
disabled={pending || !value.trim()}
|
||||||
className={`px-4 py-2 rounded border ${opt.tone} text-[12px] font-mono inline-flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed`}
|
className="px-4 py-2 rounded border border-[#e0c080] bg-[#e0c080]/10 hover:bg-[#e0c080]/20 text-[#e0c080] text-[12px] font-mono inline-flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{pending ? "queueing…" : "launch"}
|
{pending ? "enfileirando…" : "investigar"}
|
||||||
{!pending && <ArrowUpRight size={12} />}
|
{!pending && <ArrowUpRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
45
web/components/site-header.tsx
Normal file
45
web/components/site-header.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* SiteHeader — top navigation present on the homepage.
|
||||||
|
*
|
||||||
|
* Magazine-style — clean, with the brand mark left and a slim nav right.
|
||||||
|
* Sub-pages use BureauNav (sticky with breadcrumbs) instead.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AuthBar } from "./auth-bar";
|
||||||
|
|
||||||
|
export function SiteHeader({ locale }: { locale: "pt-br" | "en" }) {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-[rgba(224,192,128,0.15)]">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 md:px-8 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<Link href="/" className="flex items-center gap-2 group">
|
||||||
|
<span className="text-[#e0c080] text-lg">▍</span>
|
||||||
|
<span className="font-display text-xl text-[#e7ecf3] group-hover:text-[#e0c080] transition-colors">
|
||||||
|
The Disclosure Bureau
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-1 text-[12px] font-mono">
|
||||||
|
<NavLink href="/sightings" label={locale === "en" ? "Sightings" : "Avistamentos"} />
|
||||||
|
<NavLink href="/witnesses" label={locale === "en" ? "Witnesses" : "Testemunhas"} />
|
||||||
|
<NavLink href="/objects" label={locale === "en" ? "Craft" : "Objetos"} />
|
||||||
|
<NavLink href="/locations" label={locale === "en" ? "Locations" : "Locais"} />
|
||||||
|
<NavLink href="/operations" label={locale === "en" ? "Programs" : "Programas"} />
|
||||||
|
<NavLink href="/bureau" label={locale === "en" ? "Case files" : "Casos"} />
|
||||||
|
<NavLink href="/search" label={locale === "en" ? "Search" : "Busca"} />
|
||||||
|
<span className="ml-2"><AuthBar /></span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ href, label }: { href: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="px-2.5 py-1.5 rounded text-[#9aa6b8] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue