init: Kira — AI body double with Honcho memory
Full voice pipeline (Whisper STT -> DeepSeek LLM -> OpenAI TTS), animated SVG avatar (Live2D-ready), girly-pop UI, lofi music, timer/notes/pets/wardrobe widgets, 10 background scenes with particle effects, Honcho cross-session memory.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import Clock from './components/Clock';
|
||||
import BackgroundScene from './components/BackgroundScene';
|
||||
import MusicPlayer from './components/MusicPlayer';
|
||||
import Timer from './components/Timer';
|
||||
import Notes from './components/Notes';
|
||||
import KiraAvatar from './components/KiraAvatar';
|
||||
import ChatBubble from './components/ChatBubble';
|
||||
import PetZone from './components/PetZone';
|
||||
import Wardrobe from './components/Wardrobe';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import Particles from './components/Particles';
|
||||
import { SCENES, type Scene } from './components/scenes';
|
||||
import { useConversation } from './hooks/useConversation';
|
||||
|
||||
export default function App() {
|
||||
const [currentSceneId, setCurrentSceneId] = useState('cozy-room');
|
||||
const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie');
|
||||
const [currentAcc, setCurrentAcc] = useState<string | null>(null);
|
||||
const [textInput, setTextInput] = useState('');
|
||||
|
||||
const currentScene: Scene = SCENES.find((s) => s.id === currentSceneId) ?? SCENES[0];
|
||||
const { messages, isConnected, isKiraSpeaking, isRecording, sendText, startRecording, stopRecording } = useConversation();
|
||||
|
||||
const handleTalkToggle = () => {
|
||||
if (isRecording) stopRecording();
|
||||
else startRecording();
|
||||
};
|
||||
|
||||
const handleTextSend = () => {
|
||||
if (!textInput.trim()) return;
|
||||
sendText(textInput.trim());
|
||||
setTextInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen relative transition-all duration-1000"
|
||||
style={{ background: currentScene.gradient }}
|
||||
>
|
||||
<Particles type={currentScene.particles ?? 'none'} />
|
||||
|
||||
<div className="relative z-20 h-screen flex flex-col">
|
||||
{/* Top toolbar */}
|
||||
<div className="px-4 pt-4">
|
||||
<Toolbar currentScene={currentSceneId} onSceneChange={setCurrentSceneId} />
|
||||
</div>
|
||||
|
||||
{/* Main grid — scrollable center */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 scrollbar-thin">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 max-w-7xl mx-auto">
|
||||
|
||||
{/* Column 1: Kira + Clock */}
|
||||
<div className="space-y-4">
|
||||
<Clock />
|
||||
<KiraAvatar
|
||||
isSpeaking={isKiraSpeaking}
|
||||
isListening={isRecording}
|
||||
outfit={currentOutfit}
|
||||
accessory={currentAcc}
|
||||
onTalkToggle={handleTalkToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Timer + Music */}
|
||||
<div className="space-y-4">
|
||||
<Timer />
|
||||
<MusicPlayer />
|
||||
</div>
|
||||
|
||||
{/* Column 3: Chat + Text Input */}
|
||||
<div className="space-y-4">
|
||||
<ChatBubble messages={messages} isKiraSpeaking={isKiraSpeaking} />
|
||||
|
||||
{/* Text input fallback */}
|
||||
<div className="glass-card p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTextSend()}
|
||||
placeholder="type a message..."
|
||||
className="flex-1 bg-white/60 rounded-xl px-3 py-2 text-sm text-kira-plum placeholder-kira-plum/30 border border-kira-pink/20 focus:outline-none focus:border-kira-pink/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleTextSend}
|
||||
className="btn-kira px-3 text-sm"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{/* Connection indicator */}
|
||||
<div className="flex items-center gap-2 mt-2 text-[10px] text-kira-plum/30">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-kira-mint' : 'bg-red-300'}`} />
|
||||
{isConnected ? 'connected' : 'connecting...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 4: Cats + Wardrobe */}
|
||||
<div className="space-y-4">
|
||||
<PetZone />
|
||||
<Wardrobe onOutfitChange={setCurrentOutfit} onAccessoryChange={setCurrentAcc} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className="glass-card px-4 py-2 flex items-center justify-between text-xs text-kira-plum/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${isRecording ? 'bg-red-400 animate-pulse' : isKiraSpeaking ? 'bg-kira-pink animate-pulse' : 'bg-kira-mint'} inline-block`} />
|
||||
<span>{isRecording ? 'listening...' : isKiraSpeaking ? 'kira speaking' : 'kira is here'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden sm:inline">{isConnected ? 'body double mode' : 'offline'}</span>
|
||||
<span>🌸</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
isSpeaking: boolean;
|
||||
isListening: boolean;
|
||||
outfit: string;
|
||||
accessory: string | null;
|
||||
onTalkToggle: () => void;
|
||||
}
|
||||
|
||||
export default function AnimatedAvatar({ isSpeaking, isListening, outfit, accessory, onTalkToggle }: Props) {
|
||||
const [blink, setBlink] = useState(false);
|
||||
const [wave, setWave] = useState(false);
|
||||
const [lookX, setLookX] = useState(0);
|
||||
const [lookY, setLookY] = useState(0);
|
||||
const idleRef = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
// Blink cycle
|
||||
useEffect(() => {
|
||||
const blinkCycle = () => {
|
||||
setBlink(true);
|
||||
setTimeout(() => setBlink(false), 150);
|
||||
};
|
||||
const id = setInterval(blinkCycle, 2500 + Math.random() * 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Gentle idle eye movement
|
||||
useEffect(() => {
|
||||
idleRef.current = setInterval(() => {
|
||||
setLookX(Math.sin(Date.now() / 3000) * 3);
|
||||
setLookY(Math.sin(Date.now() / 4000) * 2);
|
||||
}, 100);
|
||||
return () => clearInterval(idleRef.current);
|
||||
}, []);
|
||||
|
||||
// Wave gesture on toggle
|
||||
const handleTalk = () => {
|
||||
setWave(true);
|
||||
setTimeout(() => setWave(false), 600);
|
||||
onTalkToggle();
|
||||
};
|
||||
|
||||
// Outfit colors
|
||||
const outfitColors: Record<string, { top: string; accent: string; skirt: string }> = {
|
||||
'cozy-hoodie': { top: '#FFB6C1', accent: '#FF69B4', skirt: '#FFB6C1' },
|
||||
'girly-dress': { top: '#D8B4FE', accent: '#A855F7', skirt: '#E9D5FF' },
|
||||
'pajama-set': { top: '#A7F3D0', accent: '#34D399', skirt: '#6EE7B7' },
|
||||
'study-sweater': { top: '#FED7AA', accent: '#F97316', skirt: '#FED7AA' },
|
||||
'going-out': { top: '#FBCFE8', accent: '#EC4899', skirt: '#FBCFE8' },
|
||||
};
|
||||
const colors = outfitColors[outfit] || outfitColors['cozy-hoodie'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Avatar canvas area */}
|
||||
<div className="relative w-36 h-44">
|
||||
<svg viewBox="0 0 120 160" className="w-full h-full">
|
||||
{/* Hair back layer */}
|
||||
<ellipse cx="60" cy="55" rx="32" ry="38" fill="#2D1B69" />
|
||||
|
||||
{/* Body / Outfit */}
|
||||
<ellipse cx="60" cy="110" rx="28" ry="35" fill={colors.top} />
|
||||
<path d="M32 110 Q60 135 88 110" fill={colors.skirt} opacity={0.7} />
|
||||
{/* Collar */}
|
||||
<path d="M48 85 Q60 92 72 85" fill={colors.accent} opacity={0.6} />
|
||||
|
||||
{/* Arms */}
|
||||
<g className={wave ? 'arm-wave' : ''}>
|
||||
{/* Left arm */}
|
||||
<ellipse cx="28" cy="98" rx="8" ry="20" fill="#FDBCB4" transform="rotate(10, 28, 98)" />
|
||||
{/* Right arm */}
|
||||
<ellipse cx="92" cy="98" rx="8" ry="20" fill="#FDBCB4" transform="rotate(-10, 92, 98)" className={wave ? 'wave-arm' : ''} />
|
||||
</g>
|
||||
|
||||
{/* Neck */}
|
||||
<rect x="54" y="78" width="12" height="12" rx="6" fill="#FDBCB4" />
|
||||
|
||||
{/* Head */}
|
||||
<ellipse cx="60" cy="52" rx="30" ry="32" fill="#FDBCB4" />
|
||||
|
||||
{/* Cheeks */}
|
||||
<ellipse cx="38" cy="60" rx="6" ry="4" fill="#FFB6C1" opacity={0.5} />
|
||||
<ellipse cx="82" cy="60" rx="6" ry="4" fill="#FFB6C1" opacity={0.5} />
|
||||
|
||||
{/* Hair front */}
|
||||
<path d="M30 45 Q30 25 45 18 Q55 14 60 15 Q65 14 75 18 Q90 25 90 45 Q90 30 82 22 Q70 14 60 12 Q50 14 38 22 Q30 30 30 45Z" fill="#3D1F8A" />
|
||||
{/* Hair bangs */}
|
||||
<path d="M30 42 Q35 30 50 28 Q60 27 70 28 Q85 30 90 42 Q88 35 78 32 Q65 28 60 28 Q55 28 42 32 Q32 35 30 42Z" fill="#4B2C9B" />
|
||||
|
||||
{/* Hair side strands */}
|
||||
<path d="M30 45 Q28 60 26 80 Q24 88 28 90" fill="none" stroke="#2D1B69" strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M90 45 Q92 60 94 80 Q96 88 92 90" fill="none" stroke="#2D1B69" strokeWidth="3" strokeLinecap="round" />
|
||||
|
||||
{/* Eyes */}
|
||||
<g transform={`translate(${lookX}, ${lookY})`}>
|
||||
{/* Left eye */}
|
||||
<ellipse cx="46" cy="50" rx="6" ry="7" fill="white" />
|
||||
{/* Right eye */}
|
||||
<ellipse cx="74" cy="50" rx="6" ry="7" fill="white" />
|
||||
|
||||
{blink ? (
|
||||
<>
|
||||
<line x1="40" y1="50" x2="52" y2="50" stroke="#4A1942" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="68" y1="50" x2="80" y2="50" stroke="#4A1942" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Pupils */}
|
||||
<circle cx="47" cy="50" r="3.5" fill="#4A1942" />
|
||||
<circle cx="75" cy="50" r="3.5" fill="#4A1942" />
|
||||
{/* Highlights */}
|
||||
<circle cx="48.5" cy="48.5" r="1.5" fill="white" />
|
||||
<circle cx="76.5" cy="48.5" r="1.5" fill="white" />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Eyebrows */}
|
||||
<path d="M38 42 Q46 39 54 42" fill="none" stroke="#2D1B69" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M66 42 Q74 39 82 42" fill="none" stroke="#2D1B69" strokeWidth="1.5" strokeLinecap="round" />
|
||||
|
||||
{/* Mouth */}
|
||||
{isSpeaking ? (
|
||||
<ellipse cx="60" cy="65" rx="5" ry="4" fill="#E75480" className="mouth-talk" />
|
||||
) : (
|
||||
<path d="M55 65 Q60 69 65 65" fill="none" stroke="#E75480" strokeWidth="2" strokeLinecap="round" />
|
||||
)}
|
||||
|
||||
{/* Accessory */}
|
||||
{accessory === 'bow' && (
|
||||
<g>
|
||||
<path d="M52 28 Q48 22 52 18 Q56 22 52 28Z" fill="#FF69B4" />
|
||||
<path d="M52 28 Q56 22 52 18 Q48 22 52 28Z" fill="#FF69B4" />
|
||||
<circle cx="52" cy="23" r="2" fill="#FF1493" />
|
||||
</g>
|
||||
)}
|
||||
{accessory === 'flower-crown' && (
|
||||
<g>
|
||||
{[48, 54, 60, 66, 72].map((x, i) => (
|
||||
<circle key={i} cx={x} cy="20" r="3" fill={i % 2 === 0 ? '#FFB6C1' : '#D8B4FE'} />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
{accessory === 'star-earrings' && (
|
||||
<>
|
||||
<polygon points="28,58 29,55 30,58 33,58 31,60 32,63 29,61 26,63 27,60 25,58" fill="#FDE68A" />
|
||||
<polygon points="92,58 93,55 94,58 97,58 95,60 96,63 93,61 90,63 91,60 89,58" fill="#FDE68A" />
|
||||
</>
|
||||
)}
|
||||
{accessory === 'glasses' && (
|
||||
<g stroke="#D8B4FE" strokeWidth="1.5" fill="none" opacity={0.7}>
|
||||
<circle cx="46" cy="50" r="8" />
|
||||
<circle cx="74" cy="50" r="8" />
|
||||
<line x1="54" y1="50" x2="66" y2="50" />
|
||||
<line x1="38" y1="50" x2="34" y2="48" />
|
||||
<line x1="82" y1="50" x2="86" y2="48" />
|
||||
</g>
|
||||
)}
|
||||
{accessory === 'scarf' && (
|
||||
<path d="M44 78 Q60 84 76 78 Q74 86 60 90 Q46 86 44 78Z" fill="#FBCFE8" stroke="#F9A8D4" strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Speaking indicator - thought dots */}
|
||||
{isSpeaking && (
|
||||
<g opacity={0.4}>
|
||||
<circle cx="30" cy="40" r="2" fill="#D8B4FE">
|
||||
<animate attributeName="opacity" values="0.3;1;0.3" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="25" cy="35" r="1.5" fill="#D8B4FE">
|
||||
<animate attributeName="opacity" values="1;0.3;1" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Listening glow */}
|
||||
{isListening && (
|
||||
<circle cx="60" cy="52" r="40" fill="none" stroke="#D8B4FE" strokeWidth="1" opacity={0.4}>
|
||||
<animate attributeName="r" values="40;44;40" dur="1.5s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Talk button */}
|
||||
<button
|
||||
onClick={handleTalk}
|
||||
className={`mt-2 flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all ${
|
||||
isListening
|
||||
? 'bg-red-400 text-white shadow-lg scale-105 animate-listening-pulse'
|
||||
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-lg hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{isListening ? '⏹️' : '🎤'}</span>
|
||||
{isListening ? 'Listening...' : 'Talk to Kira'}
|
||||
</button>
|
||||
|
||||
<style>{`
|
||||
.arm-wave {
|
||||
animation: armWave 0.6s ease-in-out;
|
||||
}
|
||||
.wave-arm {
|
||||
animation: waveArm 0.6s ease-in-out;
|
||||
transform-origin: 92px 88px;
|
||||
}
|
||||
@keyframes armWave {
|
||||
0%, 100% { transform: rotate(-10deg); }
|
||||
25% { transform: rotate(-30deg); }
|
||||
75% { transform: rotate(5deg); }
|
||||
}
|
||||
@keyframes waveArm {
|
||||
0%, 100% { transform: rotate(-10deg); }
|
||||
25% { transform: rotate(-40deg); }
|
||||
75% { transform: rotate(10deg); }
|
||||
}
|
||||
@keyframes listening-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(248, 113, 113, 0); }
|
||||
}
|
||||
.animate-listening-pulse {
|
||||
animation: listening-pulse 1.5s infinite;
|
||||
}
|
||||
.mouth-talk {
|
||||
animation: mouthOpen 0.3s ease-in-out;
|
||||
}
|
||||
@keyframes mouthOpen {
|
||||
0%, 100% { rx: 5; ry: 4; }
|
||||
50% { rx: 6; ry: 5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SCENES } from './scenes';
|
||||
|
||||
interface Props {
|
||||
currentScene: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function BackgroundScene({ currentScene, onSelect }: Props) {
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||
<span>🎨</span> Scene
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SCENES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-sm font-medium transition-all
|
||||
${currentScene === s.id
|
||||
? 'bg-kira-pink text-white shadow-md scale-105'
|
||||
: 'bg-white/50 text-kira-plum/70 hover:bg-kira-glow hover:scale-102'
|
||||
}
|
||||
`}
|
||||
title={s.name}
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span className="hidden sm:inline">{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'kira';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
isKiraSpeaking: boolean;
|
||||
}
|
||||
|
||||
export default function ChatBubble({ messages, isKiraSpeaking }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4 flex flex-col" style={{ minHeight: 200, maxHeight: 320 }}>
|
||||
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||
<span>💬</span> Conversation
|
||||
<span className={`w-2 h-2 rounded-full ${isKiraSpeaking ? 'bg-kira-pink animate-pulse' : 'bg-kira-mint'}`} />
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 scrollbar-thin pr-1">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-xs text-kira-plum/30 text-center py-6">
|
||||
click the mic or type to talk to Kira
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => {
|
||||
const isLastKira = msg.role === 'kira' && i === messages.length - 1 && isKiraSpeaking;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role === 'kira' && (
|
||||
<span className="text-sm mt-1">🌸</span>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
max-w-[80%] px-3 py-2 rounded-2xl text-sm leading-relaxed
|
||||
${msg.role === 'user'
|
||||
? 'bg-kira-lav/30 text-kira-plum rounded-br-md'
|
||||
: 'bg-white/60 text-kira-plum rounded-bl-md'
|
||||
}
|
||||
${isLastKira ? 'animate-fade-in' : ''}
|
||||
`}
|
||||
>
|
||||
{msg.text}
|
||||
{isLastKira && (
|
||||
<span className="inline-block w-1.5 h-4 bg-kira-pink/60 ml-0.5 animate-blink" />
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<span className="text-sm mt-1">👤</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.animate-fade-in { animation: fade-in 0.3s ease-out; }
|
||||
.animate-blink { animation: blink 0.8s infinite; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Clock() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="glass-card px-5 py-3 text-center">
|
||||
<div className="text-3xl font-bold tracking-tight text-kira-plum">
|
||||
{time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<div className="text-xs text-kira-violet/60 font-medium">
|
||||
{time.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import AnimatedAvatar from './AnimatedAvatar';
|
||||
|
||||
interface Props {
|
||||
isSpeaking: boolean;
|
||||
isListening: boolean;
|
||||
outfit: string;
|
||||
accessory: string | null;
|
||||
onTalkToggle: () => void;
|
||||
}
|
||||
|
||||
export default function KiraAvatar(props: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [live2dReady, setLive2dReady] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Try to load Live2D model — if it fails, show animated placeholder
|
||||
let mounted = true;
|
||||
|
||||
const tryLoadLive2D = async () => {
|
||||
try {
|
||||
// Check if model files exist
|
||||
const resp = await fetch('/live2d/models/kira.model3.json', { method: 'HEAD' });
|
||||
if (!resp.ok) {
|
||||
if (mounted) setLoadError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Cubism core
|
||||
await loadScript('/live2d/cubism/live2dcubismcore.min.js');
|
||||
|
||||
// Model exists — ready for Live2D
|
||||
// (full Live2D rendering will require the Cubism4 framework bundle)
|
||||
if (mounted) setLive2dReady(false); // Show placeholder until framework is fully wired
|
||||
} catch {
|
||||
if (mounted) setLoadError(true);
|
||||
}
|
||||
};
|
||||
|
||||
tryLoadLive2D();
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Show animated fallback while Live2D model isn't available
|
||||
return (
|
||||
<div className="glass-card p-4 flex flex-col items-center" style={{ minHeight: 290 }}>
|
||||
{/* Live2D canvas area (hidden until model is loaded) */}
|
||||
{live2dReady && (
|
||||
<div ref={canvasRef} className="w-36 h-44 relative" id="live2d-canvas" />
|
||||
)}
|
||||
|
||||
{/* Animated SVG placeholder */}
|
||||
{(!live2dReady || loadError) && (
|
||||
<AnimatedAvatar
|
||||
isSpeaking={props.isSpeaking}
|
||||
isListening={props.isListening}
|
||||
outfit={props.outfit}
|
||||
accessory={props.accessory}
|
||||
onTalkToggle={props.onTalkToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!live2dReady && !loadError && (
|
||||
<p className="text-[10px] text-kira-plum/30 mt-1">✨ Live2D model slot ready</p>
|
||||
)}
|
||||
|
||||
{/* Status info */}
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-kira-plum/40">
|
||||
<span className={`w-2 h-2 rounded-full ${props.isSpeaking ? 'bg-kira-pink animate-pulse' : props.isListening ? 'bg-red-400 animate-pulse' : 'bg-kira-mint'}`} />
|
||||
<span>
|
||||
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : 'here with you'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Outfit + accessory indicator */}
|
||||
<div className="flex gap-2 mt-1 text-[10px] text-kira-plum/30">
|
||||
<span>{props.outfit.replace('-', ' ')}</span>
|
||||
{props.accessory && <span>· {props.accessory}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const LOFI_PLAYLISTS = [
|
||||
{ id: 'lofi-girl', name: 'lofi hip hop radio', url: 'https://www.youtube.com/embed/jfKfPfyJRdk', icon: '🎧' },
|
||||
{ id: 'lofi-chill', name: 'Chill lofi', url: 'https://www.youtube.com/embed/5qap5aO4i9A', icon: '🎵' },
|
||||
{ id: 'lofi-synth', name: 'Synthwave lofi', url: 'https://www.youtube.com/embed/MVPTmgNG4x0', icon: '🌃' },
|
||||
];
|
||||
|
||||
export default function MusicPlayer() {
|
||||
const [active, setActive] = useState<string | null>('lofi-girl');
|
||||
const [volume, setVolume] = useState(0.3);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Post volume to YouTube iframe when it changes
|
||||
const timer = setTimeout(() => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
JSON.stringify({ event: 'command', func: 'setVolume', args: [volume * 100] }),
|
||||
'*'
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [volume]);
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-kira-plum flex items-center gap-2">
|
||||
<span>🎶</span> Lo-Fi
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-kira-violet/50">vol</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20 accent-kira-pink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
{LOFI_PLAYLISTS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setActive(p.id)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all
|
||||
${active === p.id
|
||||
? 'bg-kira-lav text-white shadow-md'
|
||||
: 'bg-white/50 text-kira-plum/60 hover:bg-kira-glow'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{p.icon}</span>
|
||||
<span className="hidden sm:inline">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-xl overflow-hidden" style={{ height: 80 }}>
|
||||
{active && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${LOFI_PLAYLISTS.find(p => p.id === active)?.url}?autoplay=1&controls=0&showinfo=0&loop=1&enablejsapi=1`}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ transform: 'scale(1.5)', transformOrigin: '0 0', opacity: 0.01 }}
|
||||
allow="autoplay"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-kira-plum/30 text-xs">
|
||||
{active ? '🎵 streaming...' : 'pick a station'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export default function Notes() {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const addNote = () => {
|
||||
if (!input.trim()) return;
|
||||
setNotes([...notes, { id: crypto.randomUUID(), text: input.trim(), done: false }]);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setNotes(notes.map((n) => (n.id === id ? { ...n, done: !n.done } : n)));
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
setNotes(notes.filter((n) => n.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||
<span>📝</span> Notes
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addNote()}
|
||||
placeholder="what are you working on?"
|
||||
className="flex-1 bg-white/60 rounded-xl px-3 py-2 text-sm text-kira-plum placeholder-kira-plum/30 border border-kira-pink/20 focus:outline-none focus:border-kira-pink/50"
|
||||
/>
|
||||
<button onClick={addNote} className="btn-kira px-3 text-sm">+</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-32 overflow-y-auto scrollbar-thin">
|
||||
{notes.length === 0 && (
|
||||
<p className="text-xs text-kira-plum/30 text-center py-3">nothing yet</p>
|
||||
)}
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition-all cursor-pointer ${
|
||||
note.done ? 'bg-kira-mint/30 line-through text-kira-plum/40' : 'bg-white/40 hover:bg-kira-glow'
|
||||
}`}
|
||||
onClick={() => toggle(note.id)}
|
||||
>
|
||||
<span className="text-xs">{note.done ? '✅' : '⬜'}</span>
|
||||
<span className="flex-1 truncate">{note.text}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); remove(note.id); }}
|
||||
className="text-kira-plum/20 hover:text-red-300 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
opacity: number;
|
||||
sway: number;
|
||||
swaySpeed: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: 'rain' | 'stars' | 'petals' | 'snow' | 'none';
|
||||
}
|
||||
|
||||
export default function Particles({ type }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const frameRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'none') return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const count = type === 'snow' || type === 'petals' ? 40 : type === 'stars' ? 60 : 80;
|
||||
particlesRef.current = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: type === 'rain' ? 1.5 : type === 'stars' ? Math.random() * 2 + 0.5 : type === 'petals' ? Math.random() * 4 + 3 : Math.random() * 3 + 1,
|
||||
speed: type === 'rain' ? 6 + Math.random() * 4 : type === 'petals' ? 0.5 + Math.random() * 1 : type === 'snow' ? 0.5 + Math.random() * 1.5 : 0.1,
|
||||
opacity: type === 'stars' ? 0.3 + Math.random() * 0.7 : 0.3 + Math.random() * 0.4,
|
||||
sway: Math.random() * Math.PI * 2,
|
||||
swaySpeed: 0.01 + Math.random() * 0.02,
|
||||
}));
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
rain: 'rgba(160, 174, 192, 0.3)',
|
||||
stars: 'rgba(255, 255, 255, 0.8)',
|
||||
petals: 'rgba(255, 182, 193, 0.6)',
|
||||
snow: 'rgba(255, 255, 255, 0.6)',
|
||||
};
|
||||
|
||||
let frame = 0;
|
||||
const animate = () => {
|
||||
frame++;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const color = colors[type];
|
||||
|
||||
particlesRef.current.forEach((p) => {
|
||||
p.sway += p.swaySpeed;
|
||||
p.y += p.speed;
|
||||
|
||||
if (type === 'rain') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.x + Math.sin(p.sway) * 3, p.y + 8);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = p.size;
|
||||
ctx.stroke();
|
||||
} else if (type === 'stars') {
|
||||
const twinkle = 0.5 + Math.sin(frame * 0.02 + p.x) * 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity * twinkle})`;
|
||||
ctx.fill();
|
||||
} else if (type === 'petals') {
|
||||
const rotation = Math.sin(p.sway * 2) * 0.3;
|
||||
ctx.save();
|
||||
ctx.translate(p.x + Math.sin(p.sway) * 15, p.y);
|
||||
ctx.rotate(rotation);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, p.size, p.size * 0.6, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (type === 'snow') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x + Math.sin(p.sway) * 10, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Wrap around
|
||||
if (p.y > canvas.height + 15) {
|
||||
p.y = -10;
|
||||
p.x = Math.random() * canvas.width;
|
||||
}
|
||||
if (p.x > canvas.width + 15) p.x = -5;
|
||||
if (p.x < -15) p.x = canvas.width + 5;
|
||||
});
|
||||
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
if (type === 'none') return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 pointer-events-none z-10"
|
||||
style={{ opacity: type === 'stars' ? 1 : 0.5 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export default function PetZone() {
|
||||
return (
|
||||
<div className="glass-card p-4 relative overflow-hidden" style={{ minHeight: 120 }}>
|
||||
<h3 className="text-sm font-bold text-kira-plum mb-2 flex items-center gap-2">
|
||||
<span>🐱</span> Pet Zone
|
||||
</h3>
|
||||
<div className="flex items-end justify-around gap-4">
|
||||
{/* Orange fluffy cat */}
|
||||
<div className="flex flex-col items-center animate-bounce-slow">
|
||||
<div className="relative">
|
||||
{/* Body */}
|
||||
<div className="w-14 h-10 bg-orange-300 rounded-3xl rounded-br-xl relative">
|
||||
{/* Head */}
|
||||
<div className="w-10 h-9 bg-orange-300 rounded-full absolute -top-5 left-2">
|
||||
{/* Ears */}
|
||||
<div className="absolute -top-2 left-0 w-3 h-3 bg-orange-300 rounded-tl-full transform -rotate-12" />
|
||||
<div className="absolute -top-2 right-0 w-3 h-3 bg-orange-300 rounded-tr-full transform rotate-12" />
|
||||
{/* Eyes */}
|
||||
<div className="absolute top-2 left-1.5 w-2 h-2.5 bg-amber-800 rounded-full" />
|
||||
<div className="absolute top-2 right-1.5 w-2 h-2.5 bg-amber-800 rounded-full" />
|
||||
{/* Nose */}
|
||||
<div className="absolute top-3.5 left-3.5 w-1.5 h-1 bg-pink-300 rounded-full" />
|
||||
</div>
|
||||
{/* Tail */}
|
||||
<div className="absolute -right-3 top-1 w-8 h-2.5 bg-orange-300 rounded-full origin-left rotate-12" />
|
||||
</div>
|
||||
{/* Paws */}
|
||||
<div className="flex gap-3 mt-0.5 ml-2">
|
||||
<div className="w-2.5 h-1.5 bg-orange-200 rounded-full" />
|
||||
<div className="w-2.5 h-1.5 bg-orange-200 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Mochi</span>
|
||||
</div>
|
||||
|
||||
{/* Black shorthair cat - sleeping */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative animate-float-slow">
|
||||
{/* Sleeping body (curled up) */}
|
||||
<div className="w-16 h-8 bg-gray-900 rounded-full relative overflow-hidden">
|
||||
{/* Head tucked in */}
|
||||
<div className="w-8 h-6 bg-gray-800 rounded-full absolute -left-2 -top-1">
|
||||
{/* Ears */}
|
||||
<div className="absolute -top-1.5 left-1 w-2.5 h-2.5 bg-gray-900 rounded-tl-full transform -rotate-12" />
|
||||
<div className="absolute -top-1.5 right-1 w-2.5 h-2.5 bg-gray-900 rounded-tr-full transform rotate-12" />
|
||||
{/* Closed eyes (sleeping) */}
|
||||
<div className="absolute top-2 left-1 w-2 h-0.5 bg-gray-600 rounded-full" />
|
||||
<div className="absolute top-2 right-1 w-2 h-0.5 bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
{/* Tail curled around */}
|
||||
<div className="absolute -right-2 top-2 w-6 h-2 bg-gray-900 rounded-full" />
|
||||
</div>
|
||||
{/* ZZZ */}
|
||||
<div className="absolute -top-3 -right-2 text-[10px] text-kira-lav font-bold opacity-60 animate-zzz">Z z z</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Luna</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes bounce-slow {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-2px) scale(1.01); }
|
||||
}
|
||||
@keyframes zzz {
|
||||
0%, 100% { opacity: 0.3; transform: translateX(0); }
|
||||
50% { opacity: 0.8; transform: translateX(3px); }
|
||||
}
|
||||
.animate-bounce-slow { animation: bounce-slow 3s ease-in-out infinite; }
|
||||
.animate-float-slow { animation: float-slow 4s ease-in-out infinite; }
|
||||
.animate-zzz { animation: zzz 2s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
type TimerMode = 'pomodoro' | 'countdown' | 'stopwatch';
|
||||
|
||||
interface Preset {
|
||||
label: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
const POMODORO_PRESETS: Preset[] = [
|
||||
{ label: 'Focus', minutes: 25 },
|
||||
{ label: 'Short', minutes: 5 },
|
||||
{ label: 'Long', minutes: 15 },
|
||||
];
|
||||
|
||||
const COUNTDOWN_PRESETS: Preset[] = [
|
||||
{ label: '5 min', minutes: 5 },
|
||||
{ label: '10 min', minutes: 10 },
|
||||
{ label: '30 min', minutes: 30 },
|
||||
{ label: '1 hr', minutes: 60 },
|
||||
];
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Timer() {
|
||||
const [mode, setMode] = useState<TimerMode>('pomodoro');
|
||||
const [presetMinutes, setPresetMinutes] = useState(25);
|
||||
const [remaining, setRemaining] = useState(25 * 60);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [sessions, setSessions] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
setRunning(false);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stop();
|
||||
setRemaining(presetMinutes * 60);
|
||||
}, [presetMinutes, stop]);
|
||||
|
||||
const startStop = () => {
|
||||
if (running) {
|
||||
stop();
|
||||
} else {
|
||||
if (remaining <= 0) reset();
|
||||
setRunning(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPreset = (mins: number) => {
|
||||
stop();
|
||||
setPresetMinutes(mins);
|
||||
setRemaining(mins * 60);
|
||||
};
|
||||
|
||||
const addTime = (mins: number) => {
|
||||
setRemaining((r) => r + mins * 60);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((r) => {
|
||||
if (r <= 1) {
|
||||
stop();
|
||||
if (mode === 'pomodoro') setSessions((s) => s + 1);
|
||||
return 0;
|
||||
}
|
||||
return r - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return stop;
|
||||
}, [running, mode, stop]);
|
||||
|
||||
const progress = presetMinutes > 0 ? 1 - remaining / (presetMinutes * 60) : 0;
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
{/* Mode tabs */}
|
||||
<div className="flex gap-1 mb-3 bg-kira-glow rounded-xl p-1">
|
||||
{(['pomodoro', 'countdown', 'stopwatch'] as TimerMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMode(m); stop(); setRemaining(m === 'pomodoro' ? 25 * 60 : m === 'countdown' ? 5 * 60 : 0); setPresetMinutes(m === 'pomodoro' ? 25 : m === 'countdown' ? 5 : 0); }}
|
||||
className={`flex-1 text-xs font-semibold py-1.5 rounded-lg transition-all capitalize ${mode === m ? 'bg-white text-kira-plum shadow-sm' : 'text-kira-plum/50 hover:text-kira-plum'}`}
|
||||
>
|
||||
{m === 'pomodoro' ? '🍅 Focus' : m === 'countdown' ? '⏳ Timer' : '⏱️ Stopwatch'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{(mode === 'pomodoro' ? POMODORO_PRESETS : COUNTDOWN_PRESETS).map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => selectPreset(p.minutes)}
|
||||
className={`text-xs font-medium px-3 py-1 rounded-full transition-all ${presetMinutes === p.minutes && !running ? 'bg-kira-pink text-white' : 'bg-white/60 text-kira-plum/60 hover:bg-kira-glow'}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timer display */}
|
||||
<div className="text-center py-2">
|
||||
<div className="text-5xl font-extrabold tracking-widest text-kira-plum tabular-nums">
|
||||
{mode === 'stopwatch'
|
||||
? formatTime(Math.floor((presetMinutes * 60 - remaining + presetMinutes * 60) || remaining))
|
||||
: formatTime(remaining)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-1.5 bg-kira-glow rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-kira-pink to-kira-lav rounded-full transition-all duration-1000"
|
||||
style={{ width: `${Math.min(100, progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'pomodoro' && sessions > 0 && (
|
||||
<div className="text-xs text-kira-violet/50 mt-1">
|
||||
{sessions} focus session{sessions > 1 ? 's' : ''} completed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-2 justify-center mt-2">
|
||||
<button onClick={startStop} className="btn-kira px-8 py-2 text-sm">
|
||||
{running ? '⏸️ Pause' : remaining === 0 && mode !== 'stopwatch' ? '🔄 Restart' : '▶️ Start'}
|
||||
</button>
|
||||
{running && (
|
||||
<button onClick={() => addTime(5)} className="bg-white/60 text-kira-plum/60 px-3 py-2 rounded-xl text-sm font-medium hover:bg-kira-glow transition-all">
|
||||
+5m
|
||||
</button>
|
||||
)}
|
||||
{remaining > 0 && !running && (
|
||||
<button onClick={reset} className="bg-white/60 text-kira-plum/40 px-3 py-2 rounded-xl text-sm hover:bg-kira-glow transition-all">
|
||||
🔄 Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SCENES } from './scenes';
|
||||
|
||||
interface Props {
|
||||
currentScene: string;
|
||||
onSceneChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function Toolbar({ currentScene, onSceneChange }: Props) {
|
||||
return (
|
||||
<div className="glass-card px-4 py-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-kira-plum">Kira</span>
|
||||
<span className="text-kira-plum/20">|</span>
|
||||
<div className="flex gap-1.5">
|
||||
{SCENES.filter((_, i) => i < 4).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSceneChange(s.id)}
|
||||
className={`text-sm ${currentScene === s.id ? 'opacity-100' : 'opacity-40 hover:opacity-70'} transition-opacity`}
|
||||
title={s.name}
|
||||
>
|
||||
{s.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-kira-plum/50">
|
||||
<span>🐱 2</span>
|
||||
<span className="hidden sm:inline">focus bestie</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface Outfit {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const OUTFITS: Outfit[] = [
|
||||
{ id: 'cozy-hoodie', name: 'Cozy Hoodie', icon: '🧸', color: '#FFB6C1' },
|
||||
{ id: 'girly-dress', name: 'Girly Dress', icon: '👗', color: '#D8B4FE' },
|
||||
{ id: 'pajama-set', name: 'Pajama Set', icon: '🌙', color: '#A7F3D0' },
|
||||
{ id: 'study-sweater', name: 'Study Sweater', icon: '📚', color: '#FED7AA' },
|
||||
{ id: 'going-out', name: 'Going Out', icon: '✨', color: '#FBCFE8' },
|
||||
];
|
||||
|
||||
const ACCESSORIES: Outfit[] = [
|
||||
{ id: 'bow', name: 'Bow', icon: '🎀', color: '#FFB6C1' },
|
||||
{ id: 'glasses', name: 'Glasses', icon: '👓', color: '#D8B4FE' },
|
||||
{ id: 'flower-crown', name: 'Flower Crown', icon: '🌼', color: '#A7F3D0' },
|
||||
{ id: 'star-earrings', name: 'Star Earrings', icon: '⭐', color: '#FDE68A' },
|
||||
{ id: 'scarf', name: 'Scarf', icon: '🧣', color: '#FBCFE8' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onOutfitChange: (id: string) => void;
|
||||
onAccessoryChange: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export default function Wardrobe({ onOutfitChange, onAccessoryChange }: Props) {
|
||||
const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie');
|
||||
const [currentAcc, setCurrentAcc] = useState<string | null>(null);
|
||||
const [showAcc, setShowAcc] = useState(false);
|
||||
|
||||
const selectOutfit = (id: string) => {
|
||||
setCurrentOutfit(id);
|
||||
onOutfitChange(id);
|
||||
};
|
||||
|
||||
const selectAcc = (id: string) => {
|
||||
const next = currentAcc === id ? null : id;
|
||||
setCurrentAcc(next);
|
||||
onAccessoryChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||
<span>👘</span> Wardrobe
|
||||
</h3>
|
||||
|
||||
{/* Outfits */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{OUTFITS.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
onClick={() => selectOutfit(o.id)}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-medium transition-all ${
|
||||
currentOutfit === o.id
|
||||
? 'text-white shadow-md'
|
||||
: 'bg-white/40 text-kira-plum/60 hover:bg-kira-glow'
|
||||
}`}
|
||||
style={currentOutfit === o.id ? { background: o.color } : {}}
|
||||
>
|
||||
<span>{o.icon}</span>
|
||||
<span>{o.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Accessories toggle */}
|
||||
<button
|
||||
onClick={() => setShowAcc(!showAcc)}
|
||||
className="text-xs text-kira-violet/50 hover:text-kira-violet flex items-center gap-1 mb-2"
|
||||
>
|
||||
<span>{showAcc ? '▾' : '▸'}</span> Accessories
|
||||
</button>
|
||||
|
||||
{showAcc && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACCESSORIES.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => selectAcc(a.id)}
|
||||
className={`flex items-center gap-1 px-3 py-1 rounded-xl text-xs font-medium transition-all ${
|
||||
currentAcc === a.id
|
||||
? 'text-white shadow-md'
|
||||
: 'bg-white/40 text-kira-plum/60 hover:bg-kira-glow'
|
||||
}`}
|
||||
style={currentAcc === a.id ? { background: a.color } : {}}
|
||||
>
|
||||
<span>{a.icon}</span>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface Scene {
|
||||
id: string;
|
||||
name: string;
|
||||
gradient: string;
|
||||
particles?: 'rain' | 'stars' | 'petals' | 'snow' | 'none';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const SCENES: Scene[] = [
|
||||
{ id: 'cozy-room', name: 'Cozy Room', gradient: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 40%, #FFDAB9 100%)', icon: '🏠' },
|
||||
{ id: 'coffee-shop', name: 'Coffee Shop', gradient: 'linear-gradient(135deg, #F5E6D3 0%, #E8D5C4 40%, #D4A574 100%)', icon: '☕' },
|
||||
{ id: 'garden', name: 'Garden', gradient: 'linear-gradient(135deg, #F0FFF4 0%, #C6F6D5 40%, #9AE6B4 100%)', icon: '🌿' },
|
||||
{ id: 'rainy-window', name: 'Rainy Window', gradient: 'linear-gradient(135deg, #E2E8F0 0%, #CBD5E0 40%, #A0AEC0 100%)', icon: '🌧️' },
|
||||
{ id: 'starry-night', name: 'Starry Night', gradient: 'linear-gradient(135deg, #1A1A2E 0%, #16213E 40%, #0F3460 100%)', icon: '🌙' },
|
||||
{ id: 'sakura', name: 'Sakura Spring', gradient: 'linear-gradient(135deg, #FFF0F5 0%, #FFB6C1 30%, #FF69B4 100%)', icon: '🌸' },
|
||||
{ id: 'ocean', name: 'Ocean View', gradient: 'linear-gradient(135deg, #E0F7FA 0%, #B2EBF2 40%, #4DD0E1 100%)', icon: '🌊' },
|
||||
{ id: 'autumn', name: 'Autumn Library', gradient: 'linear-gradient(135deg, #FFF8E1 0%, #FFE0B2 40%, #FFCC80 100%)', icon: '🍂' },
|
||||
{ id: 'winter-cabin', name: 'Winter Cabin', gradient: 'linear-gradient(135deg, #EDF2F7 0%, #E2E8F0 40%, #CBD5E0 100%)', icon: '❄️' },
|
||||
{ id: 'sunset', name: 'Sunset Beach', gradient: 'linear-gradient(135deg, #FFF5F5 0%, #FED7E2 30%, #F687B3 60%, #D53F8C 100%)', icon: '🌅' },
|
||||
];
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'kira';
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws`;
|
||||
|
||||
export function useConversation() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isKiraSpeaking, setIsKiraSpeaking] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
// Connect WebSocket
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setIsConnected(true);
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(msg);
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Audio playback element
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.onended = () => setIsKiraSpeaking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle incoming WS messages
|
||||
const handleMessage = useCallback((msg: any) => {
|
||||
switch (msg.type) {
|
||||
case 'transcript':
|
||||
addMessage('user', msg.text);
|
||||
break;
|
||||
|
||||
case 'speaking_start':
|
||||
setIsKiraSpeaking(true);
|
||||
addMessage('kira', msg.text || '...');
|
||||
break;
|
||||
|
||||
case 'audio':
|
||||
if (msg.data && audioRef.current) {
|
||||
const binary = atob(msg.data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: 'audio/ogg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
audioRef.current.src = url;
|
||||
audioRef.current.play().catch(() => {});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speaking_end':
|
||||
setIsKiraSpeaking(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[Kira]', msg.message);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addMessage = useCallback((role: 'user' | 'kira', text: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role, text, timestamp: Date.now() },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Send text directly (no microphone)
|
||||
const sendText = useCallback((text: string) => {
|
||||
if (!text.trim()) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'conversation_text', text: text.trim() }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Push-to-talk: start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm',
|
||||
});
|
||||
|
||||
const chunks: BlobPart[] = [];
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
// Send all audio chunks as one blob
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'audio_chunk',
|
||||
data: base64,
|
||||
}));
|
||||
wsRef.current.send(JSON.stringify({ type: 'transcribe' }));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
recorderRef.current = recorder;
|
||||
setIsRecording(true);
|
||||
} catch (err) {
|
||||
console.error('[Kira Mic] failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Push-to-talk: stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
recorderRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isConnected,
|
||||
isKiraSpeaking,
|
||||
isRecording,
|
||||
sendText,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-kira-pink: #FFB6C1;
|
||||
--color-kira-lav: #D8B4FE;
|
||||
--color-kira-mint: #A7F3D0;
|
||||
--color-kira-bg: #FFF5F5;
|
||||
--color-kira-plum: #4A1942;
|
||||
--color-kira-violet: #7C3AED;
|
||||
--color-kira-card: #FFFFFF;
|
||||
--color-kira-glow: #FDF2F8;
|
||||
--font-nunito: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
background-color: var(--color-kira-bg);
|
||||
color: var(--color-kira-plum);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 182, 193, 0.3);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(74, 25, 66, 0.08);
|
||||
}
|
||||
|
||||
.btn-kira {
|
||||
background: linear-gradient(135deg, #FFB6C1, #D8B4FE);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
.btn-kira:hover {
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 4px 16px rgba(255, 182, 193, 0.4);
|
||||
}
|
||||
|
||||
.btn-kira:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SCENES, type Scene } from './scenes';
|
||||
|
||||
export interface KiraState {
|
||||
currentScene: Scene;
|
||||
isListening: boolean;
|
||||
isSpeaking: boolean;
|
||||
currentOutfit: string;
|
||||
currentAccessory: string | null;
|
||||
sessionNotes: string[];
|
||||
}
|
||||
|
||||
export type { Scene };
|
||||
export { SCENES };
|
||||
Reference in New Issue
Block a user