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:
482
frontend/src/pages/CodexEntityPage.css
Normal file
482
frontend/src/pages/CodexEntityPage.css
Normal file
@@ -0,0 +1,482 @@
|
||||
/* Codex dossier — single-spirit occult record. Same token palette as the registry:
|
||||
bg #07070d, panel rgba(16,16,26,0.85), phosphor #7cffb2, violet #b26bff,
|
||||
blood #ff3b5c (sparingly), borders rgba(124,255,178,0.18). */
|
||||
|
||||
.codex-detail-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* scanlines + vignette */
|
||||
.codex-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
/* ---- nav / states ---- */
|
||||
|
||||
.codex-detail-nav {
|
||||
max-width: 780px;
|
||||
margin: 0 auto 2.2rem;
|
||||
}
|
||||
|
||||
.codex-detail-back {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.codex-detail-back:hover,
|
||||
.codex-detail-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.codex-detail-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 5rem 0;
|
||||
animation: codex-detail-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.codex-detail-state-error {
|
||||
color: #ff3b5c;
|
||||
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- 404 / not reached ---- */
|
||||
|
||||
.codex-detail-missing {
|
||||
max-width: 560px;
|
||||
margin: 4rem auto 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
|
||||
.codex-detail-missing-sigil {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(124, 255, 178, 0.35);
|
||||
box-shadow:
|
||||
inset 0 0 30px rgba(124, 255, 178, 0.06),
|
||||
0 0 24px rgba(124, 255, 178, 0.08);
|
||||
animation: codex-detail-sigil-spin 26s linear infinite;
|
||||
}
|
||||
|
||||
.codex-detail-missing-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.4rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
margin: 0;
|
||||
color: rgba(238, 247, 240, 0.85);
|
||||
text-shadow: 0 0 18px rgba(124, 255, 178, 0.25);
|
||||
animation: codex-detail-flicker 9s linear infinite;
|
||||
}
|
||||
|
||||
/* ---- dossier sheet ---- */
|
||||
|
||||
.codex-dossier {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: clamp(1.8rem, 5vw, 3.2rem);
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 40px rgba(124, 255, 178, 0.05);
|
||||
}
|
||||
|
||||
.codex-dossier::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(124, 255, 178, 0.08);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.codex-dossier[data-rarity='rare'] {
|
||||
border-color: rgba(178, 107, 255, 0.35);
|
||||
}
|
||||
|
||||
.codex-dossier[data-rarity='mythic'] {
|
||||
border-color: rgba(255, 157, 74, 0.4);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 44px rgba(255, 157, 74, 0.1);
|
||||
}
|
||||
|
||||
/* ---- dossier header ---- */
|
||||
|
||||
.codex-dossier-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.55rem;
|
||||
padding-bottom: 1.8rem;
|
||||
border-bottom: 1px solid rgba(124, 255, 178, 0.14);
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
|
||||
.codex-dossier-glyph {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
hsl(var(--glyph-hue), 82%, 74%) 0%,
|
||||
hsl(var(--glyph-hue), 80%, 65%) 24%,
|
||||
hsla(var(--glyph-hue), 80%, 55%, 0.3) 52%,
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(2px);
|
||||
opacity: 0.92;
|
||||
animation: codex-detail-glyph-drift 8s ease-in-out infinite;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.codex-dossier-form {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
.codex-dossier-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.9rem, 5vw, 2.8rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
margin: 0.2rem 0 0;
|
||||
color: #eef7f0;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.3),
|
||||
0 0 46px rgba(178, 107, 255, 0.2);
|
||||
animation: codex-detail-flicker 11s linear infinite;
|
||||
}
|
||||
|
||||
.codex-dossier-epithet {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-style: italic;
|
||||
color: rgba(178, 107, 255, 0.8);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.26rem 0.85rem;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='common'] {
|
||||
color: #8a8f98;
|
||||
border-color: rgba(138, 143, 152, 0.45);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='uncommon'] {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='rare'] {
|
||||
color: #b26bff;
|
||||
border-color: rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='mythic'] {
|
||||
color: #ff9d4a;
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
|
||||
box-shadow: 0 0 16px rgba(255, 157, 74, 0.32), inset 0 0 8px rgba(255, 157, 74, 0.12);
|
||||
}
|
||||
|
||||
/* ---- sections ---- */
|
||||
|
||||
.codex-dossier-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.codex-dossier-heading {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.6);
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
.codex-dossier-heading::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 1px;
|
||||
margin-top: 0.45rem;
|
||||
background: linear-gradient(90deg, rgba(124, 255, 178, 0.5), transparent);
|
||||
}
|
||||
|
||||
.codex-dossier-persona {
|
||||
margin: 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.75;
|
||||
color: rgba(238, 247, 240, 0.85);
|
||||
}
|
||||
|
||||
.codex-dossier-quotes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.codex-dossier-quote {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(216, 230, 220, 0.8);
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(178, 107, 255, 0.35);
|
||||
}
|
||||
|
||||
.codex-dossier-none {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
/* ---- stats row ---- */
|
||||
|
||||
.codex-dossier-stats {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1px;
|
||||
background: rgba(124, 255, 178, 0.12);
|
||||
border: 1px solid rgba(124, 255, 178, 0.12);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-dossier-stat {
|
||||
background: rgba(10, 10, 18, 0.85);
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.codex-dossier-stat dt {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.42);
|
||||
}
|
||||
|
||||
.codex-dossier-stat dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 10px rgba(124, 255, 178, 0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- voice signature table ---- */
|
||||
|
||||
.codex-dossier-voice {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid rgba(124, 255, 178, 0.14);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.codex-dossier-voice th,
|
||||
.codex-dossier-voice td {
|
||||
padding: 0.6rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(124, 255, 178, 0.08);
|
||||
}
|
||||
|
||||
.codex-dossier-voice tr:last-child th,
|
||||
.codex-dossier-voice tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.codex-dossier-voice tr:nth-child(even) th,
|
||||
.codex-dossier-voice tr:nth-child(even) td {
|
||||
background: rgba(124, 255, 178, 0.03);
|
||||
}
|
||||
|
||||
.codex-dossier-voice th {
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.64rem;
|
||||
color: rgba(216, 230, 220, 0.5);
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.codex-dossier-voice td {
|
||||
color: #b26bff;
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- CTA ---- */
|
||||
|
||||
.codex-dossier-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.codex-detail-cta {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.26em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border: 1px solid #7cffb2;
|
||||
border-radius: 6px;
|
||||
padding: 0.85rem 2.2rem;
|
||||
box-shadow: 0 0 22px rgba(124, 255, 178, 0.35);
|
||||
transition: box-shadow 180ms ease, transform 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.codex-detail-cta:hover,
|
||||
.codex-detail-cta:focus-visible {
|
||||
background: #a5ffcb;
|
||||
box-shadow: 0 0 34px rgba(124, 255, 178, 0.6);
|
||||
transform: translateY(-2px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.codex-detail-cta-ghost {
|
||||
color: #7cffb2;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.codex-detail-cta-ghost:hover,
|
||||
.codex-detail-cta-ghost:focus-visible {
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
}
|
||||
|
||||
/* ---- motion ---- */
|
||||
|
||||
@keyframes codex-detail-flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
91% {
|
||||
opacity: 1;
|
||||
}
|
||||
92% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
93% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
96% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-detail-glyph-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px) scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-detail-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-detail-sigil-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.codex-dossier-name,
|
||||
.codex-dossier-glyph,
|
||||
.codex-detail-state,
|
||||
.codex-detail-missing-sigil,
|
||||
.codex-detail-missing-title {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.codex-detail-cta,
|
||||
.codex-detail-back {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
412
frontend/src/pages/CodexPage.css
Normal file
412
frontend/src/pages/CodexPage.css
Normal file
@@ -0,0 +1,412 @@
|
||||
/* The Codex — gothic registry grid. Tokens: bg #07070d, panel rgba(16,16,26,0.85),
|
||||
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18). */
|
||||
|
||||
.codex-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* scanlines + vignette */
|
||||
.codex-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
/* ---- header ---- */
|
||||
|
||||
.codex-header {
|
||||
text-align: center;
|
||||
margin: 0 auto 2.5rem;
|
||||
max-width: 760px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codex-back {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.6rem;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.codex-back:hover,
|
||||
.codex-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.codex-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(2.2rem, 6vw, 3.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
margin: 0 0 0.6rem;
|
||||
color: #eef7f0;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.35),
|
||||
0 0 46px rgba(178, 107, 255, 0.25);
|
||||
animation: codex-flicker 9s linear infinite;
|
||||
}
|
||||
|
||||
.codex-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- control pills ---- */
|
||||
|
||||
.codex-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8rem 2rem;
|
||||
margin-bottom: 2.6rem;
|
||||
}
|
||||
|
||||
.codex-pill-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.codex-pill {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.42rem 0.95rem;
|
||||
color: rgba(216, 230, 220, 0.6);
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, border-color 160ms ease, box-shadow 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.codex-pill:hover {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-pill:focus-visible {
|
||||
outline: none;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.25), 0 0 14px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.codex-pill-active,
|
||||
.codex-pill-active:hover {
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-pill-active[data-rarity='rare'] {
|
||||
background: #b26bff;
|
||||
border-color: #b26bff;
|
||||
box-shadow: 0 0 16px rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.codex-pill-active[data-rarity='mythic'] {
|
||||
background: #ff9d4a;
|
||||
border-color: #ff9d4a;
|
||||
box-shadow: 0 0 18px rgba(255, 157, 74, 0.55);
|
||||
}
|
||||
|
||||
.codex-pill-active[data-rarity='common'] {
|
||||
background: #8a8f98;
|
||||
border-color: #8a8f98;
|
||||
box-shadow: 0 0 14px rgba(138, 143, 152, 0.4);
|
||||
}
|
||||
|
||||
/* ---- states ---- */
|
||||
|
||||
.codex-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 4rem 0;
|
||||
animation: codex-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.codex-state-error {
|
||||
color: #ff3b5c;
|
||||
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- grid + cards ---- */
|
||||
|
||||
.codex-grid {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
max-width: 1280px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.codex-card-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.codex-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 1.8rem 1.4rem 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.codex-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(124, 255, 178, 0.05), transparent 35%);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.codex-card:hover,
|
||||
.codex-card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(124, 255, 178, 0.5);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(124, 255, 178, 0.12),
|
||||
0 10px 34px rgba(0, 0, 0, 0.55),
|
||||
0 0 26px rgba(124, 255, 178, 0.14);
|
||||
}
|
||||
|
||||
.codex-card:hover::before,
|
||||
.codex-card:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.codex-card:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.codex-card[data-rarity='rare']:hover,
|
||||
.codex-card[data-rarity='rare']:focus-visible {
|
||||
border-color: rgba(178, 107, 255, 0.55);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 26px rgba(178, 107, 255, 0.2);
|
||||
}
|
||||
|
||||
.codex-card[data-rarity='mythic']:hover,
|
||||
.codex-card[data-rarity='mythic']:focus-visible {
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 30px rgba(255, 157, 74, 0.22);
|
||||
}
|
||||
|
||||
/* ---- ghost glyph ---- */
|
||||
|
||||
.codex-glyph-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.codex-glyph {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
hsl(var(--glyph-hue), 82%, 74%) 0%,
|
||||
hsl(var(--glyph-hue), 80%, 65%) 24%,
|
||||
hsla(var(--glyph-hue), 80%, 55%, 0.32) 50%,
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(1.5px);
|
||||
opacity: 0.9;
|
||||
animation: codex-glyph-drift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.codex-glyph-form {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
/* ---- card body ---- */
|
||||
|
||||
.codex-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.codex-card-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.28rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.codex-card-epithet {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(178, 107, 255, 0.75);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.codex-rarity {
|
||||
display: inline-block;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.22rem 0.7rem;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='common'] {
|
||||
color: #8a8f98;
|
||||
border-color: rgba(138, 143, 152, 0.45);
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='uncommon'] {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='rare'] {
|
||||
color: #b26bff;
|
||||
border-color: rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='mythic'] {
|
||||
color: #ff9d4a;
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
|
||||
box-shadow: 0 0 14px rgba(255, 157, 74, 0.3), inset 0 0 8px rgba(255, 157, 74, 0.12);
|
||||
}
|
||||
|
||||
.codex-card-quote {
|
||||
margin: 0.5rem 0 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(216, 230, 220, 0.78);
|
||||
}
|
||||
|
||||
.codex-card-meta {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
}
|
||||
|
||||
.codex-card-discoverer {
|
||||
color: rgba(216, 230, 220, 0.38);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ---- motion ---- */
|
||||
|
||||
@keyframes codex-flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
91% {
|
||||
opacity: 1;
|
||||
}
|
||||
92% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
93% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
96% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-glyph-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.06);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.codex-title,
|
||||
.codex-glyph,
|
||||
.codex-state {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.codex-card,
|
||||
.codex-pill,
|
||||
.codex-back {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
190
frontend/src/pages/CodexPage.tsx
Normal file
190
frontend/src/pages/CodexPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// The Codex — public registry of every spirit ever contacted.
|
||||
// Filterable / sortable grid of entity dossiers, fetched from GET /api/codex.
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CodexEntity, CodexListResponse, Rarity, SortOrder } from '../lib/types'
|
||||
import './CodexPage.css'
|
||||
|
||||
type RarityFilter = Rarity | 'all'
|
||||
type LoadState = 'loading' | 'error' | 'ready'
|
||||
|
||||
const RARITIES: readonly Rarity[] = ['common', 'uncommon', 'rare', 'mythic']
|
||||
const SORTS: readonly SortOrder[] = ['recent', 'contacted']
|
||||
|
||||
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: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function CodexPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [rarity, setRarity] = useState<RarityFilter>('all')
|
||||
const [sort, setSort] = useState<SortOrder>('recent')
|
||||
const [entities, setEntities] = useState<CodexEntity[]>([])
|
||||
const [state, setState] = useState<LoadState>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setState('loading')
|
||||
const params = new URLSearchParams({ sort, limit: '120' })
|
||||
if (rarity !== 'all') params.set('rarity', rarity)
|
||||
|
||||
fetch(`/api/codex?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
|
||||
return (await res.json()) as CodexListResponse
|
||||
})
|
||||
.then((data) => {
|
||||
setEntities(data.entities)
|
||||
setState('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState('error')
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [rarity, sort])
|
||||
|
||||
return (
|
||||
<div className="codex-page">
|
||||
<div className="codex-overlay" aria-hidden="true" />
|
||||
|
||||
<header className="codex-header">
|
||||
<Link to="/" className="codex-back">
|
||||
{t('codex.header.back', '← the threshold')}
|
||||
</Link>
|
||||
<h1 className="codex-title">{t('codex.header.title', 'THE CODEX')}</h1>
|
||||
<p className="codex-subtitle">
|
||||
{t(
|
||||
'codex.header.subtitle',
|
||||
'every spirit ever contacted, catalogued by the seekers who reached them',
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="codex-controls">
|
||||
<div
|
||||
className="codex-pill-group"
|
||||
role="group"
|
||||
aria-label={t('codex.controls.rarity', 'filter by rarity')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`codex-pill${rarity === 'all' ? ' codex-pill-active' : ''}`}
|
||||
aria-pressed={rarity === 'all'}
|
||||
onClick={() => setRarity('all')}
|
||||
>
|
||||
{t('codex.filterAll')}
|
||||
</button>
|
||||
{RARITIES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={`codex-pill${rarity === r ? ' codex-pill-active' : ''}`}
|
||||
data-rarity={r}
|
||||
aria-pressed={rarity === r}
|
||||
onClick={() => setRarity(r)}
|
||||
>
|
||||
{t(`codex.rarity.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="codex-pill-group"
|
||||
role="group"
|
||||
aria-label={t('codex.controls.sort', 'sort the registry')}
|
||||
>
|
||||
{SORTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`codex-pill${sort === s ? ' codex-pill-active' : ''}`}
|
||||
aria-pressed={sort === s}
|
||||
onClick={() => setSort(s)}
|
||||
>
|
||||
{t(`codex.sort.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && (
|
||||
<p className="codex-state">{t('codex.state.loading', 'consulting the registry…')}</p>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<p className="codex-state codex-state-error" role="alert">
|
||||
{t('common.error')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'ready' && entities.length === 0 && (
|
||||
<p className="codex-state">
|
||||
{t('codex.state.empty', 'no spirits of this tier have been reached yet')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'ready' && entities.length > 0 && (
|
||||
<ul className="codex-grid">
|
||||
{entities.map((entity) => (
|
||||
<CodexCard key={entity.id} entity={entity} locale={i18n.language} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodexCard({ entity, locale }: { entity: CodexEntity; locale: string }) {
|
||||
const { t } = useTranslation()
|
||||
const discoverer = entity.discovered_by ?? t('codex.card.unknownMedium', 'unknown medium')
|
||||
|
||||
return (
|
||||
<li className="codex-card-item">
|
||||
<Link to={`/codex/${entity.id}`} className="codex-card" data-rarity={entity.rarity}>
|
||||
<div className="codex-glyph-wrap">
|
||||
<div
|
||||
className="codex-glyph"
|
||||
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="codex-glyph-form">
|
||||
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="codex-card-body">
|
||||
<h2 className="codex-card-name">{entity.name}</h2>
|
||||
<p className="codex-card-epithet">{entity.epithet}</p>
|
||||
<span className="codex-rarity" data-rarity={entity.rarity}>
|
||||
{t(`codex.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
{entity.quotes.length > 0 && (
|
||||
<blockquote className="codex-card-quote">“{entity.quotes[0]}”</blockquote>
|
||||
)}
|
||||
<p className="codex-card-meta">
|
||||
{t('codex.card.contacted', {
|
||||
count: entity.contact_count,
|
||||
defaultValue: 'contacted {{count}} times',
|
||||
defaultValue_one: 'contacted {{count}} time',
|
||||
defaultValue_other: 'contacted {{count}} times',
|
||||
})}
|
||||
</p>
|
||||
<p className="codex-card-meta codex-card-discoverer">
|
||||
{t('codex.card.discoveredBy', {
|
||||
name: discoverer,
|
||||
date: formatDate(entity.discovered_at, locale),
|
||||
defaultValue: 'discovered by {{name}} · {{date}}',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
275
frontend/src/pages/EnterPage.css
Normal file
275
frontend/src/pages/EnterPage.css
Normal file
@@ -0,0 +1,275 @@
|
||||
/* EnterPage: the threshold. Centered gothic auth panel over drifting CSS
|
||||
fog. Scoped under .enter-page. */
|
||||
|
||||
.enter-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07070d;
|
||||
color: #d8e6dd;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow: hidden;
|
||||
padding: 2rem 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------- smoky backdrop (pure CSS fog) ---------- */
|
||||
|
||||
.enter-fog {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.enter-fog::before,
|
||||
.enter-fog::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 65vmax;
|
||||
height: 65vmax;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.enter-fog::before {
|
||||
background: radial-gradient(circle, #7cffb2 0%, transparent 62%);
|
||||
top: -18%;
|
||||
left: -12%;
|
||||
animation: fog-drift-a 27s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.enter-fog::after {
|
||||
background: radial-gradient(circle, #b26bff 0%, transparent 62%);
|
||||
bottom: -22%;
|
||||
right: -14%;
|
||||
animation: fog-drift-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes fog-drift-a {
|
||||
from { transform: translate(0, 0) scale(1); }
|
||||
to { transform: translate(9vw, 6vh) scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes fog-drift-b {
|
||||
from { transform: translate(0, 0) scale(1.1); }
|
||||
to { transform: translate(-8vw, -7vh) scale(0.95); }
|
||||
}
|
||||
|
||||
/* dimmed vignette + scanlines */
|
||||
.enter-scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 40%, rgba(3, 3, 7, 0.75) 100%),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.022) 0,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- the panel ---------- */
|
||||
|
||||
.enter-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: min(92vw, 420px);
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 2.6rem 2.2rem 2rem;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow:
|
||||
0 0 70px rgba(0, 0, 0, 0.65),
|
||||
0 0 32px rgba(124, 255, 178, 0.07);
|
||||
}
|
||||
|
||||
.enter-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.7rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: #eafff3;
|
||||
text-shadow:
|
||||
0 0 16px rgba(124, 255, 178, 0.45),
|
||||
0 0 50px rgba(124, 255, 178, 0.15);
|
||||
animation: enter-flicker 6s infinite;
|
||||
}
|
||||
|
||||
@keyframes enter-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
93% { opacity: 1; }
|
||||
94% { opacity: 0.7; }
|
||||
95% { opacity: 1; }
|
||||
97% { opacity: 0.85; }
|
||||
98% { opacity: 1; }
|
||||
}
|
||||
|
||||
.enter-sub {
|
||||
margin: 0 0 1.9rem;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
/* ---------- mode toggle ---------- */
|
||||
|
||||
.enter-tabs {
|
||||
display: flex;
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
.enter-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.7rem 0;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.enter-tab:hover {
|
||||
color: #7cffb2;
|
||||
background: rgba(124, 255, 178, 0.06);
|
||||
}
|
||||
|
||||
.enter-tab.active,
|
||||
.enter-tab.active:hover {
|
||||
color: #07120c;
|
||||
background: #7cffb2;
|
||||
box-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.enter-tab:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ---------- form ---------- */
|
||||
|
||||
.enter-form {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.enter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.enter-field span {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
}
|
||||
|
||||
.enter-field input {
|
||||
background: rgba(7, 7, 13, 0.8);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 0;
|
||||
color: #eafff3;
|
||||
padding: 0.72rem 0.8rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.enter-field input:focus {
|
||||
border-color: #7cffb2;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(124, 255, 178, 0.35),
|
||||
0 0 18px rgba(124, 255, 178, 0.18);
|
||||
}
|
||||
|
||||
.enter-error {
|
||||
margin: 0 0 1.15rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: #ff3b5c;
|
||||
border: 1px solid rgba(255, 59, 92, 0.35);
|
||||
background: rgba(255, 59, 92, 0.07);
|
||||
text-shadow: 0 0 8px rgba(255, 59, 92, 0.35);
|
||||
animation: error-flicker 2.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes error-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
89% { opacity: 0.65; }
|
||||
90% { opacity: 1; }
|
||||
}
|
||||
|
||||
.enter-submit {
|
||||
width: 100%;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, #8dffc0, #4fd392);
|
||||
border: 1px solid #7cffb2;
|
||||
border-radius: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 22px rgba(124, 255, 178, 0.3),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
transition: box-shadow 0.25s ease, transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.enter-submit:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 40px rgba(124, 255, 178, 0.55),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
}
|
||||
|
||||
.enter-submit:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.enter-submit:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.enter-smallprint {
|
||||
margin: 1.6rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(216, 230, 221, 0.38);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.enter-fog::before,
|
||||
.enter-fog::after,
|
||||
.enter-title,
|
||||
.enter-error {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
129
frontend/src/pages/EnterPage.tsx
Normal file
129
frontend/src/pages/EnterPage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// Enter page: the threshold. Gothic auth panel floating in CSS fog —
|
||||
// toggles between Reconnect (login) and First contact (register), and
|
||||
// steps the medium through to the séance on success.
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import './EnterPage.css'
|
||||
|
||||
type EnterMode = 'login' | 'register'
|
||||
|
||||
export function EnterPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user, login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [mode, setMode] = useState<EnterMode>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Already seated at the table — no need to knock again.
|
||||
if (user) {
|
||||
return <Navigate to="/seance" replace />
|
||||
}
|
||||
|
||||
const switchMode = (next: EnterMode) => {
|
||||
setMode(next)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (busy) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
if (mode === 'register') {
|
||||
await register(username, password)
|
||||
} else {
|
||||
await login(username, password)
|
||||
}
|
||||
navigate('/seance')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="enter-page">
|
||||
<div className="enter-fog" aria-hidden="true" />
|
||||
<div className="enter-scanlines" aria-hidden="true" />
|
||||
|
||||
<main className="enter-panel">
|
||||
<h1 className="enter-title">{t('nav.home')}</h1>
|
||||
<p className="enter-sub">
|
||||
{mode === 'login' ? t('enter.reconnectSub') : t('enter.firstContactSub')}
|
||||
</p>
|
||||
|
||||
<div className="enter-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'login'}
|
||||
className={`enter-tab${mode === 'login' ? ' active' : ''}`}
|
||||
onClick={() => switchMode('login')}
|
||||
>
|
||||
{t('enter.reconnect')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'register'}
|
||||
className={`enter-tab${mode === 'register' ? ' active' : ''}`}
|
||||
onClick={() => switchMode('register')}
|
||||
>
|
||||
{t('enter.firstContact')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="enter-form" onSubmit={handleSubmit}>
|
||||
<label className="enter-field">
|
||||
<span>{t('enter.username')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="enter-field">
|
||||
<span>{t('enter.password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="enter-error" role="alert">
|
||||
{t('enter.failed', { message: error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" className="enter-submit" disabled={busy}>
|
||||
{mode === 'login' ? t('enter.submitLogin') : t('enter.submitRegister')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="enter-smallprint">
|
||||
{t('enter.smallPrint', {
|
||||
defaultValue: 'your medium handle is only ever stored on this server',
|
||||
})}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
466
frontend/src/pages/LandingPage.css
Normal file
466
frontend/src/pages/LandingPage.css
Normal file
@@ -0,0 +1,466 @@
|
||||
/* LandingPage: gothic x hacker front door. Scoped under .landing to avoid
|
||||
colliding with other pages' styles. */
|
||||
|
||||
.landing {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #07070d;
|
||||
color: #d8e6dd;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* scanline + vignette film over everything */
|
||||
.landing-scanlines {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.55) 100%),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smoke-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: lowercase;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.hero-kicker::before,
|
||||
.hero-kicker::after {
|
||||
content: '//';
|
||||
color: rgba(124, 255, 178, 0.4);
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
|
||||
.glitch-title {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
font-size: clamp(2.6rem, 11vw, 8.5rem);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
color: #eafff3;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.45),
|
||||
0 0 60px rgba(124, 255, 178, 0.18),
|
||||
0 0 120px rgba(178, 107, 255, 0.12);
|
||||
animation: title-flicker 5.2s infinite;
|
||||
}
|
||||
|
||||
.glitch-title::before,
|
||||
.glitch-title::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glitch-title::before {
|
||||
color: #7cffb2;
|
||||
animation: glitch-slice-a 2.9s infinite steps(1, end);
|
||||
}
|
||||
|
||||
.glitch-title::after {
|
||||
color: #b26bff;
|
||||
animation: glitch-slice-b 3.4s infinite steps(1, end);
|
||||
}
|
||||
|
||||
/* short jump-cut bursts, long eerie calm in between */
|
||||
@keyframes glitch-slice-a {
|
||||
0%, 84%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
|
||||
85% { opacity: 0.85; transform: translate(-7px, -3px); clip-path: inset(8% 0 64% 0); }
|
||||
88% { opacity: 0.85; transform: translate(5px, 2px); clip-path: inset(56% 0 20% 0); }
|
||||
91% { opacity: 0.85; transform: translate(-4px, 3px); clip-path: inset(32% 0 44% 0); }
|
||||
93% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes glitch-slice-b {
|
||||
0%, 78%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
|
||||
79% { opacity: 0.8; transform: translate(6px, 2px); clip-path: inset(62% 0 12% 0); }
|
||||
82% { opacity: 0.8; transform: translate(-5px, -2px); clip-path: inset(14% 0 70% 0); }
|
||||
85% { opacity: 0.8; transform: translate(3px, -3px); clip-path: inset(40% 0 36% 0); }
|
||||
87% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes title-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
91% { opacity: 1; }
|
||||
92% { opacity: 0.72; }
|
||||
93% { opacity: 1; }
|
||||
96% { opacity: 0.86; }
|
||||
97% { opacity: 1; }
|
||||
}
|
||||
|
||||
.hero-tagline-slot {
|
||||
min-height: 1.6em;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
display: inline-block;
|
||||
font-size: clamp(0.9rem, 2vw, 1.15rem);
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.35);
|
||||
animation: tagline-in 1.1s ease both;
|
||||
}
|
||||
|
||||
@keyframes tagline-in {
|
||||
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
|
||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.cta-primary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, #8dffc0, #4fd392);
|
||||
border: 1px solid #7cffb2;
|
||||
padding: 0.95rem 2.4rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 24px rgba(124, 255, 178, 0.35),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
transition: box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.cta-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 44px rgba(124, 255, 178, 0.6),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
}
|
||||
|
||||
.cta-primary:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cta-secondary {
|
||||
color: #b26bff;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
|
||||
padding-bottom: 2px;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.cta-secondary:hover {
|
||||
color: #d3a8ff;
|
||||
border-color: #d3a8ff;
|
||||
text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
|
||||
}
|
||||
|
||||
/* ---------- shared section chrome ---------- */
|
||||
|
||||
.landing-section {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 5rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(1.2rem, 3vw, 1.8rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
color: #eafff3;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.3);
|
||||
margin: 0 0 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-title::before,
|
||||
.section-title::after {
|
||||
content: '';
|
||||
flex: 0 0 3rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(124, 255, 178, 0.4));
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
background: linear-gradient(90deg, rgba(124, 255, 178, 0.4), transparent);
|
||||
}
|
||||
|
||||
/* ---------- live veil activity ---------- */
|
||||
|
||||
.live-dot {
|
||||
flex: none;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: #7cffb2;
|
||||
box-shadow: 0 0 10px rgba(124, 255, 178, 0.9);
|
||||
animation: live-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: rgba(124, 255, 178, 0.18);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
box-shadow: 0 0 40px rgba(124, 255, 178, 0.06);
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
padding: 1.9rem 1rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: clamp(1.6rem, 4vw, 2.6rem);
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
}
|
||||
|
||||
/* ---------- how the veil answers ---------- */
|
||||
|
||||
.modes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 1.75rem 1.35rem;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
border-color: rgba(124, 255, 178, 0.55);
|
||||
box-shadow: 0 0 30px rgba(124, 255, 178, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.mode-glyph {
|
||||
color: #7cffb2;
|
||||
margin-bottom: 1rem;
|
||||
transition: color 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
|
||||
.mode-card:hover .mode-glyph {
|
||||
color: #b26bff;
|
||||
filter: drop-shadow(0 0 8px rgba(178, 107, 255, 0.6));
|
||||
}
|
||||
|
||||
.mode-glyph-svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: #eafff3;
|
||||
margin: 0 0 0.7rem;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: rgba(216, 230, 221, 0.62);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- featured spirits ---------- */
|
||||
|
||||
.featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.spirit-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 1.9rem 1.4rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.spirit-card:hover {
|
||||
border-color: rgba(178, 107, 255, 0.55);
|
||||
box-shadow: 0 0 32px rgba(178, 107, 255, 0.14);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.spirit-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #eafff3;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.spirit-epithet {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
}
|
||||
|
||||
.rarity-badge {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid currentColor;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.spirit-card .rarity-common { color: rgba(124, 255, 178, 0.7); }
|
||||
.spirit-card .rarity-uncommon { color: #b26bff; }
|
||||
.spirit-card .rarity-rare { color: #b26bff; text-shadow: 0 0 10px rgba(178, 107, 255, 0.7); }
|
||||
.spirit-card .rarity-mythic { color: #ff3b5c; text-shadow: 0 0 10px rgba(255, 59, 92, 0.7); }
|
||||
|
||||
.spirit-quote {
|
||||
margin-top: 0.8rem;
|
||||
font-family: Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(216, 230, 221, 0.6);
|
||||
quotes: '\201C' '\201D';
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-empty {
|
||||
text-align: center;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
|
||||
.landing-footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
border-top: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
}
|
||||
|
||||
.landing-footer p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.footer-colophon {
|
||||
color: rgba(124, 255, 178, 0.45);
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---------- responsive + reduced motion ---------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.section-title::before,
|
||||
.section-title::after {
|
||||
flex-basis: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glitch-title,
|
||||
.glitch-title::before,
|
||||
.glitch-title::after,
|
||||
.hero-tagline,
|
||||
.live-dot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.glitch-title::before,
|
||||
.glitch-title::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
347
frontend/src/pages/LandingPage.tsx
Normal file
347
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
// Landing page: the hype front door. Full-viewport glitch hero over a
|
||||
// self-contained drifting-smoke canvas, live veil-activity counters,
|
||||
// the four contact modes, featured spirits from the codex, and the CTA.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { GhostGlyph } from '../components/GhostGlyph'
|
||||
import type { CodexEntity, CodexListResponse, Mode, Stats } from '../lib/types'
|
||||
import './LandingPage.css'
|
||||
|
||||
const STATS_REFRESH_MS = 15_000
|
||||
const TAGLINE_MS = 4_000
|
||||
const MODE_ORDER: Mode[] = ['radio', 'evp', 'wire', 'ouija']
|
||||
const STAT_KEYS = ['entities', 'sessions', 'utterances', 'anomalies'] as const
|
||||
|
||||
/** Hacker-occult stroke glyphs for the four contact modes (no emoji, no fonts). */
|
||||
function ModeGlyph({ mode }: { mode: Mode }) {
|
||||
const common = {
|
||||
viewBox: '0 0 48 48',
|
||||
className: 'mode-glyph-svg',
|
||||
'aria-hidden': true as const,
|
||||
}
|
||||
switch (mode) {
|
||||
case 'radio':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="24" cy="18" r="3" />
|
||||
<path d="M24 21 L15 42 M24 21 L33 42 M18.6 33 h10.8" />
|
||||
<path d="M16.5 12.5 a10.5 10.5 0 0 1 15 0" />
|
||||
<path d="M11.5 7.5 a18 18 0 0 1 25 0" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'evp':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M7 24 h4 M37 24 h4" />
|
||||
<path d="M14 20 v8 M19 14 v20 M24 18 v12 M29 10 v28 M34 17 v14" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'wire':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M14.6 31.6 L21.7 16.6 M26.3 16.6 L33.4 29.6 M15.2 34 h17.6" />
|
||||
<circle cx="12" cy="34" r="3.2" />
|
||||
<circle cx="24" cy="13" r="3.2" />
|
||||
<circle cx="36" cy="32" r="3.2" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'ouija':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
|
||||
<path d="M24 6 C29 14 39 20 39 30 A15 15 0 1 1 9 30 C9 20 19 14 24 6 Z" />
|
||||
<circle cx="24" cy="29" r="5.5" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Wisp = {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
hue: number
|
||||
alpha: number
|
||||
vy: number
|
||||
swayAmp: number
|
||||
swaySpeed: number
|
||||
phase: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight 2D-canvas fog: slow phosphor-green/violet wisps drifting
|
||||
* upward on an additive blend. Self-contained; tears itself down on unmount.
|
||||
*/
|
||||
function SmokeCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let wisps: Wisp[] = []
|
||||
const HUES = [150, 275] // phosphor green / violet
|
||||
|
||||
const spawn = (anywhere: boolean): Wisp => {
|
||||
const r = 50 + Math.random() * 120
|
||||
return {
|
||||
x: Math.random() * canvas.width,
|
||||
y: anywhere ? Math.random() * canvas.height : canvas.height + r,
|
||||
r,
|
||||
hue: HUES[Math.floor(Math.random() * HUES.length)],
|
||||
alpha: 0.025 + Math.random() * 0.05,
|
||||
vy: 0.06 + Math.random() * 0.22,
|
||||
swayAmp: 0.1 + Math.random() * 0.35,
|
||||
swaySpeed: 0.0004 + Math.random() * 0.0009,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
}
|
||||
}
|
||||
|
||||
const seed = () => {
|
||||
const count = Math.max(14, Math.min(34, Math.floor((canvas.width * canvas.height) / 42000)))
|
||||
wisps = Array.from({ length: count }, () => spawn(true))
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.clientWidth
|
||||
canvas.height = canvas.clientHeight
|
||||
seed()
|
||||
}
|
||||
|
||||
const frame = (now: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
for (let i = 0; i < wisps.length; i++) {
|
||||
const w = wisps[i]
|
||||
w.y -= w.vy
|
||||
const x = w.x + Math.sin(now * w.swaySpeed + w.phase) * w.swayAmp * 40
|
||||
if (w.y + w.r < 0) {
|
||||
wisps[i] = spawn(false)
|
||||
continue
|
||||
}
|
||||
const g = ctx.createRadialGradient(x, w.y, 0, x, w.y, w.r)
|
||||
g.addColorStop(0, `hsla(${w.hue}, 90%, 65%, ${w.alpha})`)
|
||||
g.addColorStop(1, `hsla(${w.hue}, 90%, 65%, 0)`)
|
||||
ctx.fillStyle = g
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, w.y, w.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} className="smoke-canvas" aria-hidden="true" />
|
||||
}
|
||||
|
||||
/** Count-up number that eases toward `value`; renders '—' when unknown. */
|
||||
function AnimatedNumber({ value }: { value: number | null }) {
|
||||
const [display, setDisplay] = useState<number | null>(value)
|
||||
const shownRef = useRef<number | null>(value)
|
||||
|
||||
useEffect(() => {
|
||||
if (value === null) {
|
||||
shownRef.current = null
|
||||
setDisplay(null)
|
||||
return
|
||||
}
|
||||
const from = shownRef.current ?? 0
|
||||
if (from === value) {
|
||||
setDisplay(value)
|
||||
return
|
||||
}
|
||||
const start = performance.now()
|
||||
const duration = 900
|
||||
let raf = requestAnimationFrame(function tick(now: number) {
|
||||
const p = Math.min(1, (now - start) / duration)
|
||||
const eased = 1 - Math.pow(1 - p, 3)
|
||||
const v = Math.round(from + (value - from) * eased)
|
||||
shownRef.current = v
|
||||
setDisplay(v)
|
||||
if (p < 1) raf = requestAnimationFrame(tick)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [value])
|
||||
|
||||
return <span className="stat-value">{display === null ? '—' : display.toLocaleString()}</span>
|
||||
}
|
||||
|
||||
export function LandingPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [featured, setFeatured] = useState<CodexEntity[] | null>(null)
|
||||
const [taglineIdx, setTaglineIdx] = useState(0)
|
||||
|
||||
const taglineObjects: unknown = t('landing.taglines', { returnObjects: true })
|
||||
const taglines = Array.isArray(taglineObjects)
|
||||
? taglineObjects.filter((s): s is string => typeof s === 'string')
|
||||
: []
|
||||
const title = t('common.appName')
|
||||
|
||||
// Rotate the hero tagline; the key remount re-triggers the CSS fade.
|
||||
useEffect(() => {
|
||||
if (taglines.length < 2) return
|
||||
const id = window.setInterval(() => setTaglineIdx((i) => (i + 1) % taglines.length), TAGLINE_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [taglines.length])
|
||||
|
||||
// Live veil activity: poll the public stats endpoint, fail silently.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = () => {
|
||||
fetch('/api/stats', { credentials: 'include' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`stats ${r.status}`)
|
||||
return r.json() as Promise<Stats>
|
||||
})
|
||||
.then((s) => {
|
||||
if (!cancelled) setStats(s)
|
||||
})
|
||||
.catch(() => {
|
||||
/* the veil keeps its secrets — keep old numbers or '—' */
|
||||
})
|
||||
}
|
||||
load()
|
||||
const id = window.setInterval(load, STATS_REFRESH_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Featured spirits: most-contacted three from the codex. Hidden on failure.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/codex?sort=contacted&limit=3', { credentials: 'include' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`codex ${r.status}`)
|
||||
return r.json() as Promise<CodexListResponse>
|
||||
})
|
||||
.then((d) => {
|
||||
if (!cancelled) setFeatured(d.entities.slice(0, 3))
|
||||
})
|
||||
.catch(() => {
|
||||
/* registry unreachable — section simply stays shut */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="landing">
|
||||
<div className="landing-scanlines" aria-hidden="true" />
|
||||
|
||||
<section className="hero">
|
||||
<SmokeCanvas />
|
||||
<div className="hero-content">
|
||||
<p className="hero-kicker">{t('landing.heroKicker')}</p>
|
||||
<h1 className="glitch-title" data-text={title}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="hero-tagline-slot">
|
||||
{taglines.length > 0 && (
|
||||
<span key={taglineIdx} className="hero-tagline">
|
||||
{taglines[taglineIdx % taglines.length]}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="hero-ctas">
|
||||
<button
|
||||
type="button"
|
||||
className="cta-primary"
|
||||
onClick={() => navigate(user ? '/seance' : '/enter')}
|
||||
>
|
||||
{t('landing.cta')}
|
||||
</button>
|
||||
<Link className="cta-secondary" to="/codex">
|
||||
{t('landing.ctaCodex')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-section veil-stats" aria-label={t('landing.tickerPrefix')}>
|
||||
<h2 className="section-title">
|
||||
<span className="live-dot" aria-hidden="true" />
|
||||
{t('landing.tickerPrefix')}
|
||||
</h2>
|
||||
<div className="stats-grid">
|
||||
{STAT_KEYS.map((key) => (
|
||||
<div className="stat-cell" key={key}>
|
||||
<AnimatedNumber value={stats ? stats[key] : null} />
|
||||
<span className="stat-label">{t(`landing.stats.${key}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-section modes">
|
||||
<h2 className="section-title">{t('landing.modesTitle')}</h2>
|
||||
<div className="modes-grid">
|
||||
{MODE_ORDER.map((mode) => (
|
||||
<article className="mode-card" key={mode}>
|
||||
<div className="mode-glyph">
|
||||
<ModeGlyph mode={mode} />
|
||||
</div>
|
||||
<h3 className="mode-name">{t(`landing.modes.${mode}.name`)}</h3>
|
||||
<p className="mode-desc">{t(`landing.modes.${mode}.desc`)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{featured !== null && (
|
||||
<section className="landing-section featured">
|
||||
<h2 className="section-title">{t('landing.featured')}</h2>
|
||||
{featured.length === 0 ? (
|
||||
<p className="featured-empty">{t('landing.featuredEmpty')}</p>
|
||||
) : (
|
||||
<div className="featured-grid">
|
||||
{featured.map((e) => (
|
||||
<Link to={`/codex/${e.id}`} className="spirit-card" key={e.id}>
|
||||
<GhostGlyph hue={e.visual.hue} form={e.visual.form} size={56} />
|
||||
<span className="spirit-name">{e.name}</span>
|
||||
<span className="spirit-epithet">{e.epithet}</span>
|
||||
<span className={`rarity-badge rarity-${e.rarity}`}>
|
||||
{t(`codex.rarity.${e.rarity}`)}
|
||||
</span>
|
||||
{e.quotes.length > 0 && <q className="spirit-quote">{e.quotes[0]}</q>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="landing-footer">
|
||||
<p>{t('landing.footer')}</p>
|
||||
<p className="footer-colophon">{t('landing.footerColophon')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1263
frontend/src/pages/SeancePage.css
Normal file
1263
frontend/src/pages/SeancePage.css
Normal file
File diff suppressed because it is too large
Load Diff
819
frontend/src/pages/SeancePage.tsx
Normal file
819
frontend/src/pages/SeancePage.tsx
Normal file
@@ -0,0 +1,819 @@
|
||||
// The main séance screen: ghost backdrop, planchette board, mode panels
|
||||
// (wire / evp / radio / ouija), transmission log and direct-contact row.
|
||||
//
|
||||
// Wiring notes:
|
||||
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
|
||||
// itself; this page only enqueues utterance text and polls snapshot()
|
||||
// (non-mutating) for the ouija panel readout.
|
||||
// - Each mode panel is conditionally mounted, so mic / SDR teardown happens
|
||||
// in the panel's unmount cleanup — switching modes or leaving the page
|
||||
// always releases the hardware.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { SeanceProvider, useSeance } from '../state/seance'
|
||||
import type { Toast } from '../state/seance'
|
||||
import { GhostCanvas } from '../three/GhostCanvas'
|
||||
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
|
||||
import { PlanchetteBoard } from '../components/PlanchetteBoard'
|
||||
import { Transcript } from '../components/Transcript'
|
||||
import { PlanchetteMachine } from '../lib/planchette'
|
||||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||||
import { EvpListener } from '../lib/evp'
|
||||
import {
|
||||
isSupported,
|
||||
RtlSdr,
|
||||
SpectrumAnomalyDetector,
|
||||
SWEEP_END_MHZ,
|
||||
SWEEP_START_MHZ,
|
||||
} from '../lib/sdr'
|
||||
import { persistLanguage, storedLanguage } from '../i18n'
|
||||
import type { Language, Mode } from '../lib/types'
|
||||
import './SeancePage.css'
|
||||
|
||||
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
|
||||
const TOAST_TTL_MS = 6000
|
||||
/** SDR sample rate must match RtlSdr.open()'s default. */
|
||||
const SDR_SAMPLE_RATE_HZ = 2_048_000
|
||||
|
||||
export function SeancePage() {
|
||||
const { user, checking } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="seance-loading" role="status">
|
||||
<div className="loading-sigil" aria-hidden>
|
||||
⟁
|
||||
</div>
|
||||
<p className="loading-text">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
return (
|
||||
<SeanceProvider>
|
||||
<SeanceSession username={user.username} />
|
||||
</SeanceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SeancePage
|
||||
|
||||
// ---- the séance itself (rendered inside SeanceProvider) ----
|
||||
|
||||
function SeanceSession({ username }: { username: string }) {
|
||||
const {
|
||||
state,
|
||||
audioPlayer,
|
||||
setMode,
|
||||
setPassive,
|
||||
setLanguage,
|
||||
summon,
|
||||
ask,
|
||||
playUtterance,
|
||||
dismissToast,
|
||||
} = useSeance()
|
||||
const { logout } = useAuth()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const [departed, setDeparted] = useState(false)
|
||||
const [question, setQuestion] = useState('')
|
||||
const [dossierOpen, setDossierOpen] = useState(true)
|
||||
|
||||
// The planchette machine lives for the whole session; the board ticks it.
|
||||
const machineRef = useRef(new PlanchetteMachine())
|
||||
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(null)
|
||||
|
||||
// Tell the backend which tongue we whisper in (queued while connecting).
|
||||
useEffect(() => {
|
||||
setLanguage(storedLanguage())
|
||||
}, [setLanguage])
|
||||
|
||||
// Feed every fresh utterance (greetings, fragments, replies) to the board.
|
||||
const lastEnqueuedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
const uts = state.utterances
|
||||
if (uts.length === 0) return
|
||||
const lastSeen = lastEnqueuedRef.current
|
||||
const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen)
|
||||
if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history
|
||||
const fresh = uts.slice(idx + 1)
|
||||
for (const u of fresh) machineRef.current.enqueue(u.text)
|
||||
lastEnqueuedRef.current = uts[uts.length - 1].id
|
||||
}, [state.utterances])
|
||||
|
||||
// Poll the machine's snapshot for the ouija readout (board owns ticking).
|
||||
useEffect(() => {
|
||||
let lastSig = ''
|
||||
const id = window.setInterval(() => {
|
||||
const s = machineRef.current.snapshot()
|
||||
const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}`
|
||||
if (sig !== lastSig) {
|
||||
lastSig = sig
|
||||
setBoardSnap(s)
|
||||
}
|
||||
}, 150)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Open the dossier whenever a new presence steps through.
|
||||
const entityId = state.entity?.id ?? null
|
||||
useEffect(() => {
|
||||
if (entityId) setDossierOpen(true)
|
||||
}, [entityId])
|
||||
|
||||
const ghostVisual = useMemo<GhostVisualState | null>(
|
||||
() =>
|
||||
state.entity
|
||||
? { hue: state.entity.visual.hue, form: state.entity.visual.form }
|
||||
: null,
|
||||
[state.entity],
|
||||
)
|
||||
|
||||
const ghostMood: GhostMood = state.speakingId
|
||||
? 'speaking'
|
||||
: state.status === 'gathering' || state.status === 'summoning'
|
||||
? 'gathering'
|
||||
: state.status === 'attuning'
|
||||
? 'attuning'
|
||||
: 'idle'
|
||||
|
||||
const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer])
|
||||
|
||||
const switchLanguage = (lang: Language) => {
|
||||
setLanguage(lang)
|
||||
void i18n.changeLanguage(lang)
|
||||
persistLanguage(lang)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logout()
|
||||
.catch(() => undefined)
|
||||
.finally(() => setDeparted(true))
|
||||
}
|
||||
|
||||
const streaming = state.replyStreaming.active
|
||||
const submitQuestion = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (streaming) return
|
||||
ask(question)
|
||||
setQuestion('')
|
||||
}
|
||||
|
||||
if (departed) return <Navigate to="/" replace />
|
||||
|
||||
const entityHue = state.entity?.visual.hue ?? 150
|
||||
const entity = state.entity
|
||||
|
||||
return (
|
||||
<div className="seance-page">
|
||||
<GhostCanvas
|
||||
className="seance-ghost"
|
||||
visual={ghostVisual}
|
||||
mood={ghostMood}
|
||||
getAmplitude={getAmplitude}
|
||||
/>
|
||||
|
||||
{/* ---- top bar ---- */}
|
||||
<header className="seance-topbar">
|
||||
<div className="seance-brand">
|
||||
<span className="brand-sigil" aria-hidden>
|
||||
⟁
|
||||
</span>
|
||||
{t('common.appName')}
|
||||
</div>
|
||||
|
||||
<nav className="seance-modes" aria-label="modes">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{t(`seance.modes.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="seance-top-right">
|
||||
<label className="passive-toggle" title={t('seance.passive')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.passive}
|
||||
onChange={(e) => setPassive(e.target.checked)}
|
||||
/>
|
||||
<span className="passive-track" aria-hidden />
|
||||
<span className="passive-label">{t('seance.passive')}</span>
|
||||
</label>
|
||||
|
||||
<div className="lang-toggle" role="group" aria-label="language">
|
||||
<button
|
||||
type="button"
|
||||
className={state.language === 'en' ? 'active' : ''}
|
||||
onClick={() => switchLanguage('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={state.language === 'es' ? 'active' : ''}
|
||||
onClick={() => switchLanguage('es')}
|
||||
>
|
||||
ES
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link to="/codex" className="seance-codex-link">
|
||||
{t('nav.codex')}
|
||||
</Link>
|
||||
|
||||
<span className="seance-user" title={username}>
|
||||
{username}
|
||||
</span>
|
||||
<button type="button" className="seance-logout" onClick={handleLogout}>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={`conn-dot conn-${state.connection}`}
|
||||
title={t(`seance.connection.${state.connection}`)}
|
||||
aria-label={t(`seance.connection.${state.connection}`)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ---- unstable-veil banner ---- */}
|
||||
{state.connection !== 'open' && (
|
||||
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
|
||||
<span className="conn-glyph" aria-hidden>
|
||||
⟁
|
||||
</span>
|
||||
{t(`seance.connection.${state.connection}`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- main: board + side column ---- */}
|
||||
<main className="seance-main">
|
||||
<section className="seance-board-wrap">
|
||||
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
|
||||
|
||||
{state.status && (
|
||||
<div className="seance-status-overlay" aria-live="polite">
|
||||
<span className="status-sigil" aria-hidden />
|
||||
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="seance-side">
|
||||
{entity ? (
|
||||
<section className={`spirit-dossier ${dossierOpen ? 'open' : 'closed'}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="dossier-header"
|
||||
onClick={() => setDossierOpen((o) => !o)}
|
||||
aria-expanded={dossierOpen}
|
||||
>
|
||||
<span className="dossier-title">{t('seance.entity.title')}</span>
|
||||
<span className="dossier-chevron" aria-hidden>
|
||||
{dossierOpen ? '▾' : '◂'}
|
||||
</span>
|
||||
</button>
|
||||
{dossierOpen && (
|
||||
<div className="dossier-body">
|
||||
<h3 className="dossier-name" style={{ color: `hsl(${entity.visual.hue} 85% 78%)` }}>
|
||||
{entity.name}
|
||||
</h3>
|
||||
<p className="dossier-epithet">{entity.epithet}</p>
|
||||
<div className="dossier-badges">
|
||||
<span className={`rarity-badge rarity-${entity.rarity}`}>
|
||||
{t(`seance.entity.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
{state.entityIsNew ? (
|
||||
<span className="dossier-badge badge-new">{t('seance.entity.isNew')}</span>
|
||||
) : (
|
||||
<span className="dossier-badge badge-known">
|
||||
{t('seance.entity.known', { count: entity.contact_count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.persona')}</h4>
|
||||
<p className="dossier-persona">{entity.persona}</p>
|
||||
</div>
|
||||
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.voice')}</h4>
|
||||
<dl className="voice-params">
|
||||
<div>
|
||||
<dt>id</dt>
|
||||
<dd>{entity.voice.voice_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>pitch</dt>
|
||||
<dd>{entity.voice.pitch.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>rate</dt>
|
||||
<dd>{entity.voice.rate.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>noise</dt>
|
||||
<dd>{entity.voice.noise.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>echo</dt>
|
||||
<dd>{entity.voice.echo.toFixed(2)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{entity.quotes.length > 0 && (
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.quotes')}</h4>
|
||||
<ul className="dossier-quotes">
|
||||
{entity.quotes.slice(0, 4).map((q, i) => (
|
||||
<li key={i}>“{q}”</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="spirit-dossier empty">
|
||||
<div className="dossier-body">
|
||||
<p className="dossier-none">{t('seance.entity.none')}</p>
|
||||
<p className="dossier-nonehint dim">{t('seance.entity.noneHint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mode-panel">
|
||||
{state.mode === 'wire' && <WirePanel />}
|
||||
{state.mode === 'evp' && <EvpPanel />}
|
||||
{state.mode === 'radio' && <RadioPanel />}
|
||||
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
{/* ---- bottom: transmission log + direct contact ---- */}
|
||||
<footer className="seance-bottom">
|
||||
<div className="seance-transcript-wrap">
|
||||
<div className="seance-transcript-title">
|
||||
<span>{t('seance.transcriptTitle')}</span>
|
||||
<span className="anomaly-count">
|
||||
{t('seance.anomaly.count', { count: state.anomalyCount })}
|
||||
</span>
|
||||
</div>
|
||||
<Transcript
|
||||
entries={state.transcript}
|
||||
streaming={state.replyStreaming}
|
||||
speakingId={state.speakingId}
|
||||
onReplay={playUtterance}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="seance-contact" onSubmit={submitQuestion}>
|
||||
<input
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder={t('seance.askPlaceholder')}
|
||||
disabled={streaming}
|
||||
maxLength={280}
|
||||
aria-label={t('seance.ask')}
|
||||
/>
|
||||
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
|
||||
{t('seance.ask')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="summon-btn"
|
||||
onClick={summon}
|
||||
disabled={state.status === 'summoning'}
|
||||
>
|
||||
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
|
||||
</button>
|
||||
</form>
|
||||
</footer>
|
||||
|
||||
{/* ---- toasts ---- */}
|
||||
<div className="seance-toasts" aria-live="polite">
|
||||
{state.toasts.map((toast) => (
|
||||
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ---- CRT overlays (above everything, never interactive) ---- */}
|
||||
<div className="seance-scanlines" aria-hidden />
|
||||
<div className="seance-vignette" aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- toasts ----
|
||||
|
||||
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
|
||||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [toast.id, onDismiss])
|
||||
|
||||
return (
|
||||
<div className="seance-toast" role="alert">
|
||||
<span className="toast-glyph" aria-hidden>
|
||||
⚠
|
||||
</span>
|
||||
<span className="toast-msg">
|
||||
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
aria-label={t('common.dismiss')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- wire panel: raw line telemetry ----
|
||||
|
||||
function WirePanel() {
|
||||
const { state } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const tel = state.telemetry
|
||||
|
||||
return (
|
||||
<div className="wire-panel">
|
||||
<h4 className="panel-title">{t('seance.telemetry.title')}</h4>
|
||||
{tel ? (
|
||||
<div className="telemetry-grid">
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.jitter')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.jitter_bytes_per_s.toFixed(1)} {t('seance.telemetry.units.bytes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.latencyVar')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.latency_variance_ms.toFixed(2)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.latencyMean')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.latency_mean_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.dns')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.dns_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dim flicker">{t('common.loading')}</p>
|
||||
)}
|
||||
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
|
||||
{!state.passive && (
|
||||
<p className="wire-hint dim">
|
||||
{t('seance.wire.hint', {
|
||||
defaultValue:
|
||||
'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- EVP panel: microphone voice-band listening ----
|
||||
|
||||
function EvpPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const [listening, setListening] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
const [bandDb, setBandDb] = useState<number | null>(null)
|
||||
const listenerRef = useRef<EvpListener | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const lastMeterRef = useRef(0)
|
||||
|
||||
// Release the microphone when the panel unmounts (mode switch / leaving).
|
||||
useEffect(
|
||||
() => () => {
|
||||
const l = listenerRef.current
|
||||
listenerRef.current = null
|
||||
if (l) void l.stop()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const drawScope = (db: Float32Array) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const n = db.length
|
||||
const step = Math.max(1, Math.floor(n / 160))
|
||||
const bw = w / (n / step)
|
||||
for (let i = 0, x = 0; i < n; i += step, x++) {
|
||||
const v = db[i] // dB, roughly -160..0
|
||||
const norm = Math.max(0, Math.min(1, (v + 110) / 80))
|
||||
const bh = Math.max(1, norm * h)
|
||||
ctx.fillStyle = `hsla(150 90% 65% / ${0.25 + norm * 0.6})`
|
||||
ctx.fillRect(x * bw, h - bh, Math.max(1, bw - 0.5), bh)
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
if (pending || listening) return
|
||||
setDenied(false)
|
||||
setPending(true)
|
||||
const listener = listenerRef.current ?? new EvpListener()
|
||||
listenerRef.current = listener
|
||||
try {
|
||||
await listener.start({
|
||||
onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude),
|
||||
onFrame: (db) => {
|
||||
drawScope(db)
|
||||
const now = performance.now()
|
||||
if (now - lastMeterRef.current > 200) {
|
||||
lastMeterRef.current = now
|
||||
// Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT).
|
||||
const n = db.length
|
||||
const lo = Math.floor((300 / 24000) * n)
|
||||
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n))
|
||||
let peak = -Infinity
|
||||
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
|
||||
setBandDb(Number.isFinite(peak) ? peak : null)
|
||||
}
|
||||
},
|
||||
})
|
||||
if (listenerRef.current === listener) setListening(true)
|
||||
} catch {
|
||||
if (listenerRef.current === listener) listenerRef.current = null
|
||||
setDenied(true)
|
||||
setBandDb(null)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
const l = listenerRef.current
|
||||
listenerRef.current = null
|
||||
if (l) await l.stop()
|
||||
setListening(false)
|
||||
setBandDb(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evp-panel">
|
||||
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
|
||||
<div className="evp-readout">
|
||||
<span className="dim">{t('seance.evp.band')}</span>
|
||||
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
|
||||
</div>
|
||||
{listening && (
|
||||
<p className="evp-listening flicker">
|
||||
<span className="rec-dot" aria-hidden />
|
||||
{t('seance.evp.listening')}
|
||||
</p>
|
||||
)}
|
||||
{denied && (
|
||||
<div className="panel-notice error">
|
||||
<h4>{t('seance.evp.deniedTitle')}</h4>
|
||||
<p>{t('seance.evp.denied')}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="panel-action"
|
||||
onClick={() => void (listening ? stop() : start())}
|
||||
disabled={pending}
|
||||
>
|
||||
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- radio panel: RTL-SDR FM sweep ----
|
||||
|
||||
function RadioPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const supported = useMemo(() => isSupported(), [])
|
||||
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
|
||||
const sdrRef = useRef<RtlSdr | null>(null)
|
||||
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const lastTuneUiRef = useRef(0)
|
||||
|
||||
// Power down the dongle on unmount (mode switch / leaving the page).
|
||||
useEffect(
|
||||
() => () => {
|
||||
const sdr = sdrRef.current
|
||||
sdrRef.current = null
|
||||
detectorRef.current = null
|
||||
if (sdr) {
|
||||
sdr.stopSweep()
|
||||
void sdr.close()
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const drawWaterfallRow = (db: Float64Array) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
// Scroll the existing waterfall down one row, paint the new one on top.
|
||||
ctx.drawImage(canvas, 0, 1)
|
||||
const n = db.length
|
||||
const bw = w / n
|
||||
for (let i = 0; i < n; i++) {
|
||||
const norm = Math.max(0, Math.min(1, (db[i] + 90) / 70))
|
||||
ctx.fillStyle = `hsl(${272 - norm * 122} ${45 + norm * 50}% ${5 + norm * 52}%)`
|
||||
ctx.fillRect(i * bw, 0, Math.ceil(bw), 1)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const sdr = sdrRef.current
|
||||
sdrRef.current = null
|
||||
detectorRef.current = null
|
||||
if (sdr) {
|
||||
sdr.stopSweep()
|
||||
void sdr.close()
|
||||
}
|
||||
setPhase('idle')
|
||||
setTunedMhz(null)
|
||||
}, [])
|
||||
|
||||
const start = async () => {
|
||||
if (!supported || sdrRef.current || phase === 'busy') return
|
||||
setError(null)
|
||||
setPhase('busy')
|
||||
const sdr = new RtlSdr()
|
||||
try {
|
||||
await sdr.requestDevice()
|
||||
await sdr.open()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const name = err instanceof DOMException ? err.name : ''
|
||||
setError(
|
||||
name === 'NotFoundError' || /no device selected/i.test(msg)
|
||||
? t('seance.radio.noDevice')
|
||||
: /claim/i.test(msg)
|
||||
? t('seance.radio.claimFailed')
|
||||
: msg,
|
||||
)
|
||||
setPhase('idle')
|
||||
void sdr.close()
|
||||
return
|
||||
}
|
||||
sdrRef.current = sdr
|
||||
detectorRef.current = new SpectrumAnomalyDetector()
|
||||
setPhase('sweeping')
|
||||
void sdr
|
||||
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
|
||||
onSpectrum: (centerHz, db) => {
|
||||
if (sdrRef.current !== sdr) return
|
||||
drawWaterfallRow(db)
|
||||
const now = performance.now()
|
||||
if (now - lastTuneUiRef.current > 250) {
|
||||
lastTuneUiRef.current = now
|
||||
setTunedMhz(centerHz / 1e6)
|
||||
}
|
||||
const det = detectorRef.current
|
||||
if (det) {
|
||||
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now)
|
||||
if (a) sendAnomaly('radio', a.frequency, a.magnitude)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
if (sdrRef.current === sdr) setError(err.message)
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (sdrRef.current === sdr) setPhase('idle')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (sdrRef.current === sdr) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setPhase('idle')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="panel-notice">
|
||||
<h4>{t('seance.radio.unsupportedTitle')}</h4>
|
||||
<p>{t('seance.radio.unsupported')}</p>
|
||||
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
|
||||
<button type="button" className="panel-action" disabled>
|
||||
{t('seance.radio.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="radio-panel">
|
||||
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
|
||||
<div className="radio-readout">
|
||||
{tunedMhz !== null ? (
|
||||
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
|
||||
) : (
|
||||
<span className="dim">—</span>
|
||||
)}
|
||||
{phase === 'sweeping' && (
|
||||
<span className="dim flicker">
|
||||
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="panel-notice error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="panel-action"
|
||||
onClick={() => void (phase === 'sweeping' ? stop() : start())}
|
||||
disabled={phase === 'busy'}
|
||||
>
|
||||
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- ouija panel: what the planchette is doing ----
|
||||
|
||||
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
|
||||
const { t } = useTranslation()
|
||||
const word = snapshot?.word ?? null
|
||||
|
||||
return (
|
||||
<div className="ouija-panel">
|
||||
{word ? (
|
||||
<div className="ouija-spelling">
|
||||
<span className="dim ouija-spelling-label">
|
||||
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
|
||||
</span>
|
||||
<div className="ouija-word" aria-live="polite">
|
||||
{word.split('').map((ch, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
|
||||
}
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dim ouija-drift">
|
||||
{t('seance.ouija.drift', {
|
||||
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{snapshot && snapshot.spelled.length > 0 && (
|
||||
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user