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:
79
frontend/src/components/EntityCard.tsx
Normal file
79
frontend/src/components/EntityCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SpiritEntity } from '../lib/types'
|
||||
import { GhostGlyph } from './GhostGlyph'
|
||||
|
||||
export type EntityCardProps = {
|
||||
entity: SpiritEntity | null
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
const RARITY_CLASS: Record<string, string> = {
|
||||
common: 'rarity-common',
|
||||
uncommon: 'rarity-uncommon',
|
||||
rare: 'rarity-rare',
|
||||
mythic: 'rarity-mythic',
|
||||
}
|
||||
|
||||
/** Occult dossier of the currently-summoned spirit, wax-seal vibe. */
|
||||
export function EntityCard({ entity, isNew }: EntityCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!entity) {
|
||||
return (
|
||||
<aside className="entity-card empty">
|
||||
<h3 className="ec-title">{t('seance.entity.title')}</h3>
|
||||
<div className="ec-none">
|
||||
<div className="ec-none-sigil">⟁</div>
|
||||
<p>{t('seance.entity.none')}</p>
|
||||
<p className="dim">{t('seance.entity.noneHint')}</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="entity-card">
|
||||
<div className="ec-wax" aria-hidden="true">
|
||||
{entity.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<h3 className="ec-title">{t('seance.entity.title')}</h3>
|
||||
<div className="ec-head">
|
||||
<GhostGlyph hue={entity.visual.hue} form={entity.visual.form} size={64} />
|
||||
<div>
|
||||
<div className="ec-name glitch-hover">{entity.name}</div>
|
||||
<div className="ec-epithet">{entity.epithet}</div>
|
||||
<span className={`rarity-badge ${RARITY_CLASS[entity.rarity] ?? ''}`}>
|
||||
{t(`seance.entity.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ec-contact">
|
||||
{isNew ? t('seance.entity.isNew') : t('seance.entity.known', { count: entity.contact_count })}
|
||||
</div>
|
||||
<div className="ec-section">
|
||||
<h4>{t('seance.entity.persona')}</h4>
|
||||
<p className="ec-persona">{entity.persona}</p>
|
||||
</div>
|
||||
{entity.quotes.length > 0 && (
|
||||
<div className="ec-section">
|
||||
<h4>{t('seance.entity.quotes')}</h4>
|
||||
<ul className="ec-quotes">
|
||||
{entity.quotes.slice(0, 4).map((q, i) => (
|
||||
<li key={i}>“{q}”</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="ec-section">
|
||||
<h4>{t('seance.entity.voice')}</h4>
|
||||
<div className="ec-voice">
|
||||
<span>id {entity.voice.voice_id}</span>
|
||||
<span>pitch {entity.voice.pitch.toFixed(2)}</span>
|
||||
<span>rate {entity.voice.rate.toFixed(2)}</span>
|
||||
<span>noise {entity.voice.noise.toFixed(2)}</span>
|
||||
<span>echo {entity.voice.echo.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/GhostGlyph.tsx
Normal file
63
frontend/src/components/GhostGlyph.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// Mini ghost glyph: an SVG spirit silhouette tinted by visual.hue,
|
||||
// with small per-form variations (fairy motes, banshee veil, shade dark).
|
||||
|
||||
import type { GhostForm } from '../lib/types'
|
||||
|
||||
export type GhostGlyphProps = {
|
||||
hue: number
|
||||
form: GhostForm
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GhostGlyph({ hue, form, size = 48, className }: GhostGlyphProps) {
|
||||
const body = `hsl(${hue} 75% ${form === 'shade' ? 22 : 62}%)`
|
||||
const glow = `hsl(${hue} 90% 72%)`
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
className={`ghost-glyph ${className ?? ''}`}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`gg-${hue}-${form}`} cx="50%" cy="35%" r="70%">
|
||||
<stop offset="0%" stopColor={glow} stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor={body} stopOpacity={form === 'shade' ? 0.9 : 0.25} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{form === 'banshee' && (
|
||||
<path
|
||||
d="M22 30 C14 44 12 54 10 62 M42 30 C50 44 52 54 54 62"
|
||||
stroke={body}
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.55"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={
|
||||
form === 'fairy'
|
||||
? 'M32 8 C22 8 18 18 18 28 C18 40 22 50 24 56 C27 52 29 54 32 56 C35 54 37 52 40 56 C42 50 46 40 46 28 C46 18 42 8 32 8 Z'
|
||||
: 'M32 6 C21 6 16 17 16 29 C16 42 21 52 24 58 C27 54 29 56 32 58 C35 56 37 54 40 58 C43 52 48 42 48 29 C48 17 43 6 32 6 Z'
|
||||
}
|
||||
fill={`url(#gg-${hue}-${form})`}
|
||||
stroke={glow}
|
||||
strokeWidth="1"
|
||||
opacity={form === 'shade' ? 0.95 : 0.9}
|
||||
/>
|
||||
<circle cx="26" cy="27" r="2.2" fill={form === 'shade' ? glow : '#0a0a12'} />
|
||||
<circle cx="38" cy="27" r="2.2" fill={form === 'shade' ? glow : '#0a0a12'} />
|
||||
{form === 'fairy' && (
|
||||
<>
|
||||
<circle cx="12" cy="16" r="1.6" fill={glow} />
|
||||
<circle cx="52" cy="12" r="1.3" fill={glow} />
|
||||
<circle cx="55" cy="40" r="1.8" fill={glow} />
|
||||
<circle cx="9" cy="44" r="1.2" fill={glow} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
315
frontend/src/components/PlanchetteBoard.tsx
Normal file
315
frontend/src/components/PlanchetteBoard.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
// Gothic Ouija board rendered on canvas 2D: arc of letters, numbers,
|
||||
// YES / NO / GOODBYE, and a planchette (triangle with lens) that drifts
|
||||
// idly with a smoke-particle trail, then glides letter-by-letter to spell
|
||||
// queued words (driven by a PlanchetteMachine from src/lib/planchette.ts).
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PlanchetteMachine } from '../lib/planchette'
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
type Layout = {
|
||||
w: number
|
||||
h: number
|
||||
letters: Map<string, Point>
|
||||
numbers: Map<string, Point>
|
||||
yes: Point
|
||||
no: Point
|
||||
goodbye: Point
|
||||
center: Point
|
||||
}
|
||||
|
||||
function computeLayout(w: number, h: number): Layout {
|
||||
const letters = new Map<string, Point>()
|
||||
const numbers = new Map<string, Point>()
|
||||
const cx = w / 2
|
||||
const cy = h * 0.52
|
||||
const arc = (chars: string, radius: number, yOff: number) => {
|
||||
const span = Math.PI * 0.78
|
||||
const start = Math.PI + (Math.PI - span) / 2
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const a = start + (span * i) / (chars.length - 1)
|
||||
letters.set(chars[i], {
|
||||
x: cx + Math.cos(a) * radius,
|
||||
y: cy + yOff + Math.sin(a) * radius * 0.72,
|
||||
})
|
||||
}
|
||||
}
|
||||
arc('ABCDEFGHIJKLM', Math.min(w * 0.4, h * 0.52), 0)
|
||||
arc('NOPQRSTUVWXYZ', Math.min(w * 0.4, h * 0.52) * 0.74, h * 0.06)
|
||||
|
||||
const row = '1234567890'
|
||||
const spacing = Math.min(w * 0.07, 46)
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
numbers.set(row[i], {
|
||||
x: cx + (i - (row.length - 1) / 2) * spacing,
|
||||
y: h * 0.74,
|
||||
})
|
||||
}
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
letters,
|
||||
numbers,
|
||||
yes: { x: w * 0.18, y: h * 0.13 },
|
||||
no: { x: w * 0.82, y: h * 0.13 },
|
||||
goodbye: { x: cx, y: h * 0.9 },
|
||||
center: { x: cx, y: h * 0.45 },
|
||||
}
|
||||
}
|
||||
|
||||
type Smoke = { x: number; y: number; vx: number; vy: number; life: number; size: number }
|
||||
|
||||
/** Cheap layered-sine wander, perlin-ish enough for idle drift. */
|
||||
function wander(t: number, seed: number): number {
|
||||
return (
|
||||
Math.sin(t * 0.31 + seed) * 0.5 +
|
||||
Math.sin(t * 0.73 + seed * 1.7) * 0.3 +
|
||||
Math.sin(t * 1.31 + seed * 2.3) * 0.2
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanchetteBoardProps = {
|
||||
machine: PlanchetteMachine
|
||||
/** Tint hue (degrees) for glow accents. */
|
||||
hue?: number
|
||||
}
|
||||
|
||||
export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const machineRef = useRef(machine)
|
||||
machineRef.current = machine
|
||||
const hueRef = useRef(hue)
|
||||
hueRef.current = hue
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let last = performance.now()
|
||||
let layout: Layout | null = null
|
||||
const pos: Point = { x: 0, y: 0 }
|
||||
let initialized = false
|
||||
const smoke: Smoke[] = []
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
const dpr = Math.min(window.devicePixelRatio, 2)
|
||||
const w = parent.clientWidth
|
||||
const h = parent.clientHeight
|
||||
if (w === 0 || h === 0) return
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
layout = computeLayout(w, h)
|
||||
if (!initialized) {
|
||||
pos.x = layout.center.x
|
||||
pos.y = layout.center.y
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
if (canvas.parentElement) ro.observe(canvas.parentElement)
|
||||
|
||||
const letterTarget = (ch: string): Point | null => {
|
||||
if (!layout) return null
|
||||
if (layout.letters.has(ch)) return layout.letters.get(ch)!
|
||||
if (layout.numbers.has(ch)) return layout.numbers.get(ch)!
|
||||
return null
|
||||
}
|
||||
|
||||
const draw = (now: number) => {
|
||||
raf = requestAnimationFrame(draw)
|
||||
const dt = Math.min(64, now - last)
|
||||
last = now
|
||||
if (!layout) return
|
||||
const { w, h } = layout
|
||||
const snap = machineRef.current.tick(dt)
|
||||
const hueDeg = hueRef.current
|
||||
const accent = `hsl(${hueDeg} 90% 72%)`
|
||||
const t = now / 1000
|
||||
|
||||
// --- target: spelled letter, else idle wander ---
|
||||
let target: Point | null = null
|
||||
if (snap.current) target = letterTarget(snap.current)
|
||||
if (!target) {
|
||||
target = {
|
||||
x: layout.center.x + wander(t, 1.3) * w * 0.16,
|
||||
y: layout.center.y + wander(t * 0.83, 4.1) * h * 0.14,
|
||||
}
|
||||
}
|
||||
const speed = snap.current ? 0.16 : 0.03
|
||||
pos.x += (target.x - pos.x) * speed
|
||||
pos.y += (target.y - pos.y) * speed
|
||||
|
||||
// --- smoke trail ---
|
||||
const moving = Math.abs(target.x - pos.x) + Math.abs(target.y - pos.y) > 2
|
||||
if ((moving || snap.current) && smoke.length < 220) {
|
||||
smoke.push({
|
||||
x: pos.x + (Math.random() - 0.5) * 8,
|
||||
y: pos.y + (Math.random() - 0.5) * 8,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: -0.25 - Math.random() * 0.4,
|
||||
life: 1,
|
||||
size: 3 + Math.random() * 7,
|
||||
})
|
||||
}
|
||||
for (let i = smoke.length - 1; i >= 0; i--) {
|
||||
const s = smoke[i]
|
||||
s.x += s.vx * dt * 0.06
|
||||
s.y += s.vy * dt * 0.06
|
||||
s.life -= dt * 0.0006
|
||||
if (s.life <= 0) smoke.splice(i, 1)
|
||||
}
|
||||
|
||||
// ================= draw =================
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Board panel.
|
||||
const grad = ctx.createRadialGradient(w / 2, h * 0.45, 20, w / 2, h * 0.45, Math.max(w, h) * 0.7)
|
||||
grad.addColorStop(0, 'rgba(20, 18, 34, 0.85)')
|
||||
grad.addColorStop(1, 'rgba(7, 7, 13, 0.35)')
|
||||
ctx.fillStyle = grad
|
||||
roundRect(ctx, w * 0.03, h * 0.04, w * 0.94, h * 0.92, 18)
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 70% 60% / 0.25)`
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
|
||||
// Corner ornaments.
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 70% 60% / 0.35)`
|
||||
for (const [ox, oy] of [
|
||||
[w * 0.05, h * 0.07],
|
||||
[w * 0.95, h * 0.07],
|
||||
[w * 0.05, h * 0.93],
|
||||
[w * 0.95, h * 0.93],
|
||||
] as Array<[number, number]>) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(ox, oy, 6, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// YES / NO / GOODBYE.
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.9)`
|
||||
ctx.font = `600 ${Math.max(13, h * 0.035)}px "IBM Plex Mono", monospace`
|
||||
ctx.fillText('YES', layout.yes.x, layout.yes.y)
|
||||
ctx.fillText('NO', layout.no.x, layout.no.y)
|
||||
ctx.font = `600 ${Math.max(12, h * 0.03)}px "IBM Plex Mono", monospace`
|
||||
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)`
|
||||
ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y)
|
||||
|
||||
// Letters + numbers.
|
||||
const hovered = snap.current
|
||||
ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif`
|
||||
for (const [ch, p] of layout.letters) {
|
||||
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
|
||||
}
|
||||
ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace`
|
||||
for (const [ch, p] of layout.numbers) {
|
||||
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
|
||||
}
|
||||
|
||||
// Smoke (additive).
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
for (const s of smoke) {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = `hsla(${hueDeg} 80% 70% / ${0.08 * s.life})`
|
||||
ctx.arc(s.x, s.y, s.size * (1.6 - s.life * 0.6), 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
|
||||
// --- planchette: triangle with lens ---
|
||||
ctx.save()
|
||||
ctx.translate(pos.x, pos.y)
|
||||
const tilt = Math.sin(t * 0.9) * 0.04 + (target.x - pos.x) * 0.0006
|
||||
ctx.rotate(tilt)
|
||||
const R = Math.max(26, h * 0.085)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -R)
|
||||
ctx.lineTo(R * 0.85, R * 0.65)
|
||||
ctx.lineTo(-R * 0.85, R * 0.65)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(14, 12, 24, 0.82)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = accent
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.shadowColor = accent
|
||||
ctx.shadowBlur = snap.current ? 18 : 8
|
||||
ctx.stroke()
|
||||
ctx.shadowBlur = 0
|
||||
// Lens (window the letter shows through).
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, R * 0.08, R * 0.3, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `hsla(${hueDeg} 90% 75% / ${snap.current ? 0.3 : 0.1})`
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 90% 80% / 0.8)`
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(draw)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="planchette-board" style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r)
|
||||
ctx.arcTo(x, y + h, x, y, r)
|
||||
ctx.arcTo(x, y, x + w, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawGlyph(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ch: string,
|
||||
p: Point,
|
||||
hovered: boolean,
|
||||
accent: string,
|
||||
hueDeg: number,
|
||||
): void {
|
||||
if (hovered) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = accent
|
||||
ctx.shadowBlur = 16
|
||||
ctx.fillStyle = accent
|
||||
ctx.fillText(ch, p.x, p.y)
|
||||
ctx.restore()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / 0.5)`
|
||||
ctx.arc(p.x, p.y, 15, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
} else {
|
||||
ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`
|
||||
ctx.fillText(ch, p.x, p.y)
|
||||
}
|
||||
}
|
||||
28
frontend/src/components/TelemetryReadout.tsx
Normal file
28
frontend/src/components/TelemetryReadout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Telemetry } from '../lib/types'
|
||||
|
||||
/** Flickering terminal readout of live wire telemetry. */
|
||||
export function TelemetryReadout({ telemetry }: { telemetry: Telemetry | null }) {
|
||||
const { t } = useTranslation()
|
||||
const rows: Array<[string, string]> = telemetry
|
||||
? [
|
||||
[t('seance.telemetry.jitter'), `${telemetry.jitter_bytes_per_s.toFixed(1)} ${t('seance.telemetry.units.bytes')}`],
|
||||
[t('seance.telemetry.latencyVar'), `${telemetry.latency_variance_ms.toFixed(2)} ${t('seance.telemetry.units.ms')}`],
|
||||
[t('seance.telemetry.latencyMean'), `${telemetry.latency_mean_ms.toFixed(2)} ${t('seance.telemetry.units.ms')}`],
|
||||
[t('seance.telemetry.dns'), `${telemetry.dns_ms.toFixed(1)} ${t('seance.telemetry.units.ms')}`],
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="telemetry">
|
||||
<h4 className="telemetry-title">{t('seance.telemetry.title')}</h4>
|
||||
{rows.length === 0 && <div className="telemetry-row dim">— — —</div>}
|
||||
{rows.map(([label, value]) => (
|
||||
<div className="telemetry-row flicker" key={label}>
|
||||
<span className="telemetry-label">{label}</span>
|
||||
<span className="telemetry-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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