whung99
feat: deploy Oppy with Google API integration
0d37119
import { useState, useEffect, useRef, useCallback } from 'react';
// 4-pointed star path centered at (0,0), size ~1 unit
const STAR_PATH = 'M 0 -1 C 0.12 -0.12, 0.12 -0.12, 1 0 C 0.12 0.12, 0.12 0.12, 0 1 C -0.12 0.12, -0.12 0.12, -1 0 C -0.12 -0.12, -0.12 -0.12, 0 -1 Z';
// Smile mouth: a crescent arc
const SMILE_PATH = 'M -12 0 C -8 10, 8 10, 12 0 C 8 4, -8 4, -12 0 Z';
// Open mouth: rounder, taller
const OPEN_PATH = 'M -10 -4 C -8 12, 8 12, 10 -4 C 6 6, -6 6, -10 -4 Z';
export default function OppyFace({ phase, size = 180 }) {
const svgRef = useRef(null);
// --- Eye tracking ---
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
const handleMouseMove = useCallback((e) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const max = 5;
const factor = Math.min(dist / 300, 1);
setEyeOffset({
x: (dx / (dist || 1)) * max * factor,
y: (dy / (dist || 1)) * max * factor,
});
}, []);
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, [handleMouseMove]);
// --- Blink ---
const [blinkScale, setBlinkScale] = useState(1);
useEffect(() => {
if (phase !== 'IDLE' && phase !== 'LISTENING') return;
let timer;
const doBlink = () => {
setBlinkScale(0.05);
setTimeout(() => setBlinkScale(1), 120);
timer = setTimeout(doBlink, 2500 + Math.random() * 4000);
};
timer = setTimeout(doBlink, 2000 + Math.random() * 3000);
return () => clearTimeout(timer);
}, [phase]);
// --- Speaking mouth toggle ---
const [mouthOpen, setMouthOpen] = useState(false);
useEffect(() => {
if (phase !== 'SPEAKING') {
setMouthOpen(false);
return;
}
const interval = setInterval(() => {
setMouthOpen(prev => !prev);
}, 200 + Math.random() * 150);
return () => clearInterval(interval);
}, [phase]);
// --- Thinking: eyes spin ---
const thinkRotate = phase === 'THINKING';
const mouthPath = mouthOpen ? OPEN_PATH : SMILE_PATH;
return (
<svg
ref={svgRef}
viewBox="0 0 100 100"
width={size}
height={size}
style={{ overflow: 'visible' }}
>
<defs>
{/* Gemini star gradient */}
<radialGradient id="starGrad" cx="50%" cy="50%" r="60%" fx="35%" fy="30%">
<stop offset="0%" stopColor="#4285f4" />
<stop offset="30%" stopColor="#4285f4" />
<stop offset="50%" stopColor="#34a853" />
<stop offset="70%" stopColor="#fbbc04" />
<stop offset="90%" stopColor="#ea4335" />
</radialGradient>
{/* Mouth gradient */}
<linearGradient id="mouthGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#fbbc04" />
<stop offset="50%" stopColor="#34a853" />
<stop offset="100%" stopColor="#4285f4" />
</linearGradient>
</defs>
{/* Left eye (star) */}
<g
transform={`translate(${34 + eyeOffset.x}, ${38 + eyeOffset.y})`}
style={{
transition: 'transform 0.12s ease-out',
}}
>
<g
transform={`scale(14, ${14 * blinkScale})`}
style={{
transition: 'transform 0.08s ease-in-out',
transformOrigin: '0 0',
}}
>
<g style={{
animation: thinkRotate ? 'eyeSpin 1.5s linear infinite' : 'none',
transformOrigin: '0 0',
}}>
<path d={STAR_PATH} fill="url(#starGrad)" />
</g>
</g>
</g>
{/* Right eye (star) */}
<g
transform={`translate(${66 + eyeOffset.x}, ${38 + eyeOffset.y})`}
style={{
transition: 'transform 0.12s ease-out',
}}
>
<g
transform={`scale(14, ${14 * blinkScale})`}
style={{
transition: 'transform 0.08s ease-in-out',
transformOrigin: '0 0',
}}
>
<g style={{
animation: thinkRotate ? 'eyeSpin 1.5s linear infinite reverse' : 'none',
transformOrigin: '0 0',
}}>
<path d={STAR_PATH} fill="url(#starGrad)" />
</g>
</g>
</g>
{/* Mouth */}
<g
transform="translate(50, 65)"
style={{
transition: 'transform 0.15s ease-in-out',
}}
>
<path
d={mouthPath}
fill="url(#mouthGrad)"
style={{
transition: 'd 0.12s ease-in-out',
}}
/>
</g>
</svg>
);
}