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:
87
frontend/src/components/Transcript.tsx
Normal file
87
frontend/src/components/Transcript.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TranscriptEntry } from '../state/seance'
|
||||
|
||||
export type TranscriptProps = {
|
||||
entries: TranscriptEntry[]
|
||||
streaming: { active: boolean; text: string }
|
||||
speakingId: string | null
|
||||
onReplay: (utteranceId: string) => void
|
||||
}
|
||||
|
||||
function fmtFreq(source: string, f: number): string {
|
||||
// EVP reports Hz (voice band); radio reports MHz (FM band); wire is ambient.
|
||||
if (source === 'evp') return `${f.toFixed(0)}Hz`
|
||||
if (source === 'radio') return `${f.toFixed(1)}MHz`
|
||||
return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
|
||||
}
|
||||
|
||||
/** Terminal-style scrolling transmission log. */
|
||||
export function Transcript({ entries, streaming, speakingId, onReplay }: TranscriptProps) {
|
||||
const { t } = useTranslation()
|
||||
const boxRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const box = boxRef.current
|
||||
if (box) box.scrollTop = box.scrollHeight
|
||||
}, [entries.length, streaming.text])
|
||||
|
||||
return (
|
||||
<div className="transcript" ref={boxRef} aria-live="polite">
|
||||
{entries.length === 0 && !streaming.active && (
|
||||
<div className="tx-row tx-system dim">{t('seance.transcriptEmpty')}</div>
|
||||
)}
|
||||
{entries.map((e) => {
|
||||
switch (e.kind) {
|
||||
case 'anomaly':
|
||||
return (
|
||||
<div key={e.id} className="tx-row tx-anomaly">
|
||||
<span className="tx-glyph">⚡</span>
|
||||
<span className="tx-src">{e.source}</span>{' '}
|
||||
<span className="tx-freq">{fmtFreq(e.source, e.frequency)}</span>{' '}
|
||||
<span className="tx-mag">+{e.magnitude.toFixed(1)}dB</span>
|
||||
</div>
|
||||
)
|
||||
case 'question':
|
||||
return (
|
||||
<div key={e.id} className="tx-row tx-question">
|
||||
<span className="tx-speaker">{t('seance.you')}</span>
|
||||
<span className="tx-text"> {e.text}</span>
|
||||
</div>
|
||||
)
|
||||
case 'system':
|
||||
return (
|
||||
<div key={e.id} className="tx-row tx-system">
|
||||
{e.text}
|
||||
</div>
|
||||
)
|
||||
case 'utterance': {
|
||||
const speaking = speakingId === e.utteranceId
|
||||
return (
|
||||
<div key={e.id} className={`tx-row tx-utterance kind-${e.utKind} ${speaking ? 'speaking' : ''}`}>
|
||||
<span className="tx-speaker">{e.speaker ?? '???'}</span>
|
||||
<span className="tx-text"> “{e.text}”</span>
|
||||
{e.audioUrl && (
|
||||
<button
|
||||
className="tx-replay"
|
||||
title={t('common.replay')}
|
||||
onClick={() => onReplay(e.utteranceId)}
|
||||
>
|
||||
{speaking ? '🔊' : '▶'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
})}
|
||||
{streaming.active && (
|
||||
<div className="tx-row tx-streaming">
|
||||
<span className="tx-stream-label">{t('seance.streaming')}</span>
|
||||
<span className="tx-text smoke-text">{streaming.text}</span>
|
||||
<span className="tx-cursor">▌</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user