// Codex dossier — a single spirit's occult record at /codex/:id. // Fetches GET /api/codex/:id; 404s render a themed "not reached" state. import { useEffect, useState, type CSSProperties } from 'react' import { Link, useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { PUBLIC_ORIGIN, usePageMeta } from '../lib/pageMeta' import type { CodexEntityDetail } from '../lib/types' import './CodexEntityPage.css' type DetailState = | { status: 'loading' } | { status: 'error' } | { status: 'notfound' } | { status: 'ready'; entity: CodexEntityDetail } function formatDate(iso: string, locale: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return iso return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric', }).format(date) } /** Voice profiles are loose records — render whatever the veil preserved. */ function formatVoiceValue(value: unknown): string { if (value === null || value === undefined) return '—' if (typeof value === 'number') { return Number.isInteger(value) ? String(value) : value.toFixed(2) } if (typeof value === 'object') return JSON.stringify(value) return String(value) } export function CodexEntityPage() { const { t, i18n } = useTranslation() const { id } = useParams<{ id: string }>() const [state, setState] = useState({ status: 'loading' }) // Every spirit is a distinct, unique page — give each one its own title, // description and canonical URL so they rank individually instead of all // sharing the app's single static title. The persona doubles as the // description: it is the actual unique prose a searcher would be looking // for. Marked up as a fictional Person so previews and rich results can // never imply this is a record of a real dead human. const detail = state.status === 'ready' ? state.entity : null usePageMeta({ title: detail ? `${detail.name} — ${detail.epithet}` : undefined, description: detail?.persona, path: id ? `/codex/${id}` : undefined, structuredData: detail ? { '@context': 'https://schema.org', '@type': 'Person', name: detail.name, alternateName: detail.epithet, description: detail.persona, additionalType: 'https://schema.org/FictionalCharacter', url: `${PUBLIC_ORIGIN}/codex/${id}`, } : undefined, }) useEffect(() => { if (!id) { setState({ status: 'notfound' }) return } const controller = new AbortController() setState({ status: 'loading' }) fetch(`/api/codex/${encodeURIComponent(id)}`, { credentials: 'include', signal: controller.signal, }) .then(async (res) => { if (res.status === 404) { setState({ status: 'notfound' }) return } if (!res.ok) throw new Error(`the registry answered ${res.status}`) const entity = (await res.json()) as CodexEntityDetail setState({ status: 'ready', entity }) }) .catch(() => { if (!controller.signal.aborted) setState({ status: 'error' }) }) return () => controller.abort() }, [id]) return (