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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user