Commit
Add animated bot avatar component
commit
2bce30f
1 changed file with +57 and −0
added client/src/components/VoiceOrb.tsx +57 −0
| @@ -0,0 +1,57 @@ | ||
| 1 | +import { useEffect, useRef } from 'react' | |
| 2 | + | |
| 3 | +export type VoiceState = 'idle' | 'listening' | 'thinking' | 'speaking' | |
| 4 | + | |
| 5 | +interface VoiceOrbProps { | |
| 6 | + state: VoiceState | |
| 7 | + levelRef?: React.RefObject<number> | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function VoiceOrb({ state, levelRef }: VoiceOrbProps) { | |
| 11 | + const ringRef = useRef<HTMLDivElement>(null) | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + if (state !== 'listening' || !levelRef) return | |
| 15 | + let rafId = 0 | |
| 16 | + const tick = () => { | |
| 17 | + const ring = ringRef.current | |
| 18 | + if (ring) { | |
| 19 | + const level = levelRef.current ?? 0 | |
| 20 | + ring.style.transform = `scale(${1 + level * 0.3})` | |
| 21 | + ring.style.opacity = String(0.25 + level * 0.6) | |
| 22 | + } | |
| 23 | + rafId = requestAnimationFrame(tick) | |
| 24 | + } | |
| 25 | + rafId = requestAnimationFrame(tick) | |
| 26 | + return () => { | |
| 27 | + cancelAnimationFrame(rafId) | |
| 28 | + const ring = ringRef.current | |
| 29 | + if (ring) { | |
| 30 | + ring.style.transform = '' | |
| 31 | + ring.style.opacity = '' | |
| 32 | + } | |
| 33 | + } | |
| 34 | + }, [state, levelRef]) | |
| 35 | + | |
| 36 | + return ( | |
| 37 | + <div className={`orb-wrap orb-${state}`} aria-hidden="true"> | |
| 38 | + <div ref={ringRef} className="orb-level-ring" /> | |
| 39 | + <div className="orb-ripple orb-ripple-1" /> | |
| 40 | + <div className="orb-ripple orb-ripple-2" /> | |
| 41 | + <div className="orb"> | |
| 42 | + <div className="orb-face"> | |
| 43 | + <div className="orb-eyes"> | |
| 44 | + <span className="orb-eye" /> | |
| 45 | + <span className="orb-eye" /> | |
| 46 | + </div> | |
| 47 | + <div className="orb-mouth"> | |
| 48 | + <span className="orb-mouth-bar" /> | |
| 49 | + <span className="orb-mouth-bar" /> | |
| 50 | + <span className="orb-mouth-bar" /> | |
| 51 | + <span className="orb-mouth-bar" /> | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | + ) | |
| 57 | +} |