feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
209
frontend/src/pages/CodexEntityPage.tsx
Normal file
209
frontend/src/pages/CodexEntityPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// 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 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' })
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user