The worst bug of the session. tests/conftest.py built its engine from settings.database_url — the live database — and an autouse fixture calls drop_all() before EVERY test. So every backend run silently annihilated the real install: accounts, discovered spirits, Ghost Logs, devices, all of it. Found it because /sitemap.xml listed zero entities minutes after I had watched live séances mint real ones. Tests now use TEST_DATABASE_URL, or `<configured-db>_test` derived from it, and refuse to start at all if that ever resolves back to the production URL — this box both serves the app and holds the repo, so "don't run tests in prod" is not a workable guard. Proven: inserted a canary row into production, ran 50 tests, canary survived. Before this it would have been dropped. Also in this commit: SEO (routes/seo.py, lib/pageMeta.ts) - Live /sitemap.xml generated from real entity rows, and /robots.txt, both registered BEFORE the SPA catch-all or they'd be served index.html. Crawlers are disallowed from /seance specifically because the open door provisions a guest on arrival — a crawler would fill the users table with wanderers who never existed. - Per-route <title>, description, canonical and JSON-LD. The Codex is the indexable asset here (every spirit is unique long-form prose) and all of it previously shared one static title, so entities competed with each other instead of ranking. Entities are marked up as fictional Persons so a rich result can never imply a record of a real dead human. - public_base_url setting: absolute URLs for crawlers can't be derived from the request, since behind the tunnel the app only sees an internal host. Camera channel, first half (lib/camera.ts, llm scry path) - OllamaClient.generate() now accepts `images`; the configured chat model (minicpm-v4.5:8b) is vision-capable, so the entity can speak about what the seeker's camera actually shows. Verified against a synthetic room image: it named the pale column and the small red cube, then misread them as oak in a farmhouse parlor — real perception, in character. - Frames are captured only on an explicit act, downscaled to 768px and JPEG-compressed, never stored, and the prompt forbids describing faces or guessing identity. CameraEye carries the same generation guard as the EVP listener so closing during the permission prompt can't leave the camera live after teardown. 338 backend tests pass; 375 frontend; i18n parity holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
// 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<DetailState>({ 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 (
|
|
<div className="codex-detail-page">
|
|
<div className="codex-detail-overlay" aria-hidden="true" />
|
|
|
|
<nav className="codex-detail-nav">
|
|
<Link to="/codex" className="codex-detail-back">
|
|
{t('codex.back')}
|
|
</Link>
|
|
</nav>
|
|
|
|
{state.status === 'loading' && (
|
|
<p className="codex-detail-state">
|
|
{t('codex.detail.loading', 'summoning the dossier…')}
|
|
</p>
|
|
)}
|
|
|
|
{state.status === 'error' && (
|
|
<p className="codex-detail-state codex-detail-state-error" role="alert">
|
|
{t('common.error')}
|
|
</p>
|
|
)}
|
|
|
|
{state.status === 'notfound' && (
|
|
<div className="codex-detail-missing">
|
|
<div className="codex-detail-missing-sigil" aria-hidden="true" />
|
|
<h1 className="codex-detail-missing-title">
|
|
{t('codex.detail.notReached', 'this spirit has not been reached')}
|
|
</h1>
|
|
<Link to="/codex" className="codex-detail-cta codex-detail-cta-ghost">
|
|
{t('codex.back')}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === 'ready' && (
|
|
<Dossier entity={state.entity} locale={i18n.language} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string }) {
|
|
const { t } = useTranslation()
|
|
const discoverer = entity.discovered_by ?? t('codex.detail.unknownMedium', 'unknown medium')
|
|
|
|
const stats: Array<{ label: string; value: string }> = [
|
|
{
|
|
label: t('codex.detail.stats.sightings', 'sightings'),
|
|
value: String(entity.sightings),
|
|
},
|
|
{
|
|
label: t('codex.detail.stats.contacts', 'contacts'),
|
|
value: String(entity.contact_count),
|
|
},
|
|
{
|
|
label: t('codex.detail.stats.discoveredBy', 'discovered by'),
|
|
value: discoverer,
|
|
},
|
|
{
|
|
label: t('codex.detail.stats.discoveredAt', 'entered the registry'),
|
|
value: formatDate(entity.discovered_at, locale),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<article className="codex-dossier" data-rarity={entity.rarity}>
|
|
<header className="codex-dossier-header">
|
|
<div
|
|
className="codex-dossier-glyph"
|
|
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="codex-dossier-form">
|
|
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
|
|
</span>
|
|
<h1 className="codex-dossier-name">{entity.name}</h1>
|
|
<p className="codex-dossier-epithet">{entity.epithet}</p>
|
|
<span className="codex-dossier-rarity" data-rarity={entity.rarity}>
|
|
{t(`codex.rarity.${entity.rarity}`)}
|
|
</span>
|
|
</header>
|
|
|
|
<section className="codex-dossier-section">
|
|
<h2 className="codex-dossier-heading">{t('codex.persona')}</h2>
|
|
<p className="codex-dossier-persona">{entity.persona}</p>
|
|
</section>
|
|
|
|
<section className="codex-dossier-section">
|
|
<h2 className="codex-dossier-heading">
|
|
{t('codex.detail.utterances', 'known utterances')}
|
|
</h2>
|
|
{entity.quotes.length > 0 ? (
|
|
<ul className="codex-dossier-quotes">
|
|
{entity.quotes.map((quote, i) => (
|
|
<li key={i} className="codex-dossier-quote">
|
|
“{quote}”
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="codex-dossier-none">
|
|
{t('codex.detail.noUtterances', 'no utterances recorded yet')}
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="codex-dossier-section">
|
|
<dl className="codex-dossier-stats">
|
|
{stats.map((stat) => (
|
|
<div key={stat.label} className="codex-dossier-stat">
|
|
<dt>{stat.label}</dt>
|
|
<dd>{stat.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="codex-dossier-section">
|
|
<h2 className="codex-dossier-heading">
|
|
{t('codex.detail.voiceSignature', 'voice signature')}
|
|
</h2>
|
|
<table className="codex-dossier-voice">
|
|
<tbody>
|
|
{Object.entries(entity.voice).map(([key, value]) => (
|
|
<tr key={key}>
|
|
<th scope="row">{key.replace(/_/g, ' ')}</th>
|
|
<td>{formatVoiceValue(value)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<footer className="codex-dossier-footer">
|
|
<Link to="/seance" className="codex-detail-cta">
|
|
{t('codex.detail.attemptContact', 'attempt contact')}
|
|
</Link>
|
|
</footer>
|
|
</article>
|
|
)
|
|
}
|