Upload files to "/"
This commit is contained in:
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Navbar, NavTab, AgeGroup } from './components/Navbar';
|
||||||
|
import { StoryReader } from './components/StoryReader';
|
||||||
|
import { StoryboardView } from './components/StoryboardView';
|
||||||
|
import { ArtDirectionView } from './components/ArtDirectionView';
|
||||||
|
import { CharacterVault } from './components/CharacterVault';
|
||||||
|
import { AdaptabilityView } from './components/AdaptabilityView';
|
||||||
|
import { FormatStrategyView } from './components/FormatStrategyView';
|
||||||
|
import { ActivityCorner } from './components/ActivityCorner';
|
||||||
|
import { PitchDeckModal } from './components/PitchDeckModal';
|
||||||
|
import { BOOK_METADATA } from './data/storyData';
|
||||||
|
import { soundManager } from './utils/audio';
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<NavTab>('reader');
|
||||||
|
const [ageGroup, setAgeGroup] = useState<AgeGroup>('pictureBook');
|
||||||
|
const [isBedtimeMode, setIsBedtimeMode] = useState<boolean>(false);
|
||||||
|
const [soundEnabled, setSoundEnabled] = useState<boolean>(true);
|
||||||
|
const [isPitchDeckOpen, setIsPitchDeckOpen] = useState<boolean>(false);
|
||||||
|
const [storyboardSceneId, setStoryboardSceneId] = useState<number>(1);
|
||||||
|
|
||||||
|
// Sync global .dark class on <html> with Bedtime Mode so all dark: variants apply
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (isBedtimeMode) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [isBedtimeMode]);
|
||||||
|
|
||||||
|
const handleExploreStoryboard = (sceneId: number) => {
|
||||||
|
soundManager.playPop();
|
||||||
|
setStoryboardSceneId(sceneId);
|
||||||
|
setActiveTab('storyboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen flex flex-col font-reading transition-colors duration-500 ${
|
||||||
|
isBedtimeMode
|
||||||
|
? 'bg-slate-950 text-indigo-50'
|
||||||
|
: 'bg-[#faf6ee] text-stone-900'
|
||||||
|
}`}>
|
||||||
|
{/* Top Navigation */}
|
||||||
|
<Navbar
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
ageGroup={ageGroup}
|
||||||
|
setAgeGroup={setAgeGroup}
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
setIsBedtimeMode={setIsBedtimeMode}
|
||||||
|
soundEnabled={soundEnabled}
|
||||||
|
setSoundEnabled={setSoundEnabled}
|
||||||
|
onOpenPitchDeck={() => setIsPitchDeckOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main View Router */}
|
||||||
|
<main className="flex-1">
|
||||||
|
{activeTab === 'reader' && (
|
||||||
|
<StoryReader
|
||||||
|
ageGroup={ageGroup}
|
||||||
|
setAgeGroup={setAgeGroup}
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
soundEnabled={soundEnabled}
|
||||||
|
onExploreStoryboard={handleExploreStoryboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'storyboard' && (
|
||||||
|
<StoryboardView
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
selectedSceneId={storyboardSceneId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'art' && (
|
||||||
|
<ArtDirectionView
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'characters' && (
|
||||||
|
<CharacterVault
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'adaptability' && (
|
||||||
|
<AdaptabilityView
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'publishing' && (
|
||||||
|
<FormatStrategyView
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
onOpenPitchDeck={() => setIsPitchDeckOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activities' && (
|
||||||
|
<ActivityCorner
|
||||||
|
isBedtimeMode={isBedtimeMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className={`border-t py-8 px-4 sm:px-6 lg:px-8 transition-colors ${
|
||||||
|
isBedtimeMode
|
||||||
|
? 'bg-indigo-950/70 border-indigo-900 text-indigo-200'
|
||||||
|
: 'bg-[#f7f2e7] border-amber-200/80 text-stone-700'
|
||||||
|
}`}>
|
||||||
|
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">🦔</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-heading font-bold text-sm text-stone-900 dark:text-white">
|
||||||
|
{BOOK_METADATA.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-medium text-stone-600 dark:text-indigo-300">
|
||||||
|
Children's Storybook Concept & Interactive Story Experience
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold">
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setActiveTab('reader'); }}
|
||||||
|
className="hover:text-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Story Reader
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setActiveTab('storyboard'); }}
|
||||||
|
className="hover:text-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Storyboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setActiveTab('art'); }}
|
||||||
|
className="hover:text-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Art Direction
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setActiveTab('characters'); }}
|
||||||
|
className="hover:text-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Character Vault
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setActiveTab('adaptability'); }}
|
||||||
|
className="hover:text-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Age Matrix
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setActiveTab('publishing'); }}
|
||||||
|
className="hover:text-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
Publishing Specs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { soundManager.playPop(); setIsPitchDeckOpen(true); }}
|
||||||
|
className="text-amber-700 dark:text-amber-400 font-bold hover:underline"
|
||||||
|
>
|
||||||
|
Editorial Pitch Deck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-stone-700 dark:text-indigo-200">
|
||||||
|
<span>Crafted with</span>
|
||||||
|
<Heart className="w-3.5 h-3.5 text-rose-500 fill-rose-500" />
|
||||||
|
<span>by</span>
|
||||||
|
<span className="font-heading font-bold text-stone-900 dark:text-white tracking-wide">aimirah</span>
|
||||||
|
<span className="text-stone-500 dark:text-indigo-400">|</span>
|
||||||
|
<span className="font-heading font-bold text-amber-800 dark:text-amber-300 tracking-wide">mi4inc</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Editorial Pitch Deck Modal */}
|
||||||
|
<PitchDeckModal
|
||||||
|
isOpen={isPitchDeckOpen}
|
||||||
|
onClose={() => setIsPitchDeckOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Web Audio API Synthesizer for Children's Storybook
|
||||||
|
|
||||||
|
class SoundEffectsManager {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
public enabled: boolean = true;
|
||||||
|
|
||||||
|
private initCtx() {
|
||||||
|
if (!this.ctx && typeof window !== 'undefined') {
|
||||||
|
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (AudioCtx) {
|
||||||
|
this.ctx = new AudioCtx();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.ctx && this.ctx.state === 'suspended') {
|
||||||
|
this.ctx.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft cheerful pop
|
||||||
|
playPop() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
osc.frequency.setValueAtTime(400, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(800, now + 0.08);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.18, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.09);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentatonic magical chime
|
||||||
|
playChime() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
|
||||||
|
notes.forEach((freq, idx) => {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
const now = this.ctx.currentTime + idx * 0.07;
|
||||||
|
osc.frequency.setValueAtTime(freq, now);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.12, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft gentle raindrop plop
|
||||||
|
playRain() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const drops = [700, 1100, 850];
|
||||||
|
drops.forEach((freq, i) => {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime + i * 0.08;
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(freq * 0.5, now + 0.07);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.14, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.15);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Harp strum
|
||||||
|
playHarp() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const notes = [392.00, 523.25, 659.25, 783.99, 1046.5]; // G4, C5, E5, G5, C6
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime + i * 0.05;
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, now);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.1, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.65);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flutter / whoosh
|
||||||
|
playFlutter() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(220, now);
|
||||||
|
osc.frequency.linearRampToValueAtTime(440, now + 0.1);
|
||||||
|
osc.frequency.linearRampToValueAtTime(260, now + 0.2);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playful giggle / chirp
|
||||||
|
playGiggle() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const notes = [587.33, 739.99, 587.33, 880.00];
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime + i * 0.06;
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(freq, now);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.09, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.12);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page turn sound
|
||||||
|
playPageTurn() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const bufferSize = this.ctx.sampleRate * 0.15;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
const noise = this.ctx.createBufferSource();
|
||||||
|
noise.buffer = buffer;
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.value = 1200;
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
gain.gain.setValueAtTime(0.12, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.14);
|
||||||
|
|
||||||
|
noise.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
noise.start(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Star Celebration fanfare
|
||||||
|
playStarCelebration() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.initCtx();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const notes = [523.25, 659.25, 783.99, 1046.50, 1318.51];
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime + i * 0.08;
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, now);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.15, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.45);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playSound(type: 'pop' | 'chime' | 'rain' | 'harp' | 'flutter' | 'giggle') {
|
||||||
|
switch (type) {
|
||||||
|
case 'pop': this.playPop(); break;
|
||||||
|
case 'chime': this.playChime(); break;
|
||||||
|
case 'rain': this.playRain(); break;
|
||||||
|
case 'harp': this.playHarp(); break;
|
||||||
|
case 'flutter': this.playFlutter(); break;
|
||||||
|
case 'giggle': this.playGiggle(); break;
|
||||||
|
default: this.playPop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const soundManager = new SoundEffectsManager();
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Class-based dark variant so Bedtime Mode toggle drives every dark: style */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background-color: #fbf8f2;
|
||||||
|
color: #2c2523;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, .font-heading {
|
||||||
|
font-family: 'Fredoka', 'Quicksand', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-hand {
|
||||||
|
font-family: 'Patrick Hand', cursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-reading {
|
||||||
|
font-family: 'Quicksand', 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Custom whimsical storybook effects */
|
||||||
|
.book-shadow {
|
||||||
|
box-shadow: 0 20px 40px -15px rgba(45, 25, 20, 0.22),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.05),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-spine {
|
||||||
|
box-shadow: inset 18px 0 30px -10px rgba(0, 0, 0, 0.15),
|
||||||
|
inset -4px 0 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-texture {
|
||||||
|
background-color: #fdfaf5;
|
||||||
|
background-image: radial-gradient(#ece4d4 0.75px, transparent 0.75px), radial-gradient(#ece4d4 0.75px, #fdfaf5 0.75px);
|
||||||
|
background-size: 30px 30px;
|
||||||
|
background-position: 0 0, 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark paper texture for Bedtime Mode */
|
||||||
|
.dark .paper-texture {
|
||||||
|
background-color: #0f172a;
|
||||||
|
background-image: radial-gradient(rgba(99, 102, 241, 0.25) 0.75px, transparent 0.75px), radial-gradient(rgba(99, 102, 241, 0.18) 0.75px, #0f172a 0.75px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-slow {
|
||||||
|
animation: floatSlow 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-gentle {
|
||||||
|
animation: gentlePulse 3.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-sparkle {
|
||||||
|
animation: shimmerSparkle 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatSlow {
|
||||||
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-8px) rotate(1.5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gentlePulse {
|
||||||
|
0%, 100% { opacity: 0.9; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.03); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerSparkle {
|
||||||
|
0% { transform: scale(0.8) rotate(0deg); opacity: 0.4; }
|
||||||
|
50% { transform: scale(1.15) rotate(90deg); opacity: 1; }
|
||||||
|
100% { transform: scale(0.8) rotate(180deg); opacity: 0.4; }
|
||||||
|
}
|
||||||
@@ -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,98 @@
|
|||||||
|
export class StorySpeechNarrator {
|
||||||
|
private synth: SpeechSynthesis | null = null;
|
||||||
|
private currentUtterance: SpeechSynthesisUtterance | null = null;
|
||||||
|
private onEndCallback: (() => void) | null = null;
|
||||||
|
private onWordCallback: ((wordIndex: number) => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||||
|
this.synth = window.speechSynthesis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAvailable(): boolean {
|
||||||
|
return this.synth !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public speak(
|
||||||
|
text: string,
|
||||||
|
options?: {
|
||||||
|
rate?: number;
|
||||||
|
pitch?: number;
|
||||||
|
onEnd?: () => void;
|
||||||
|
onWord?: (index: number) => void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!this.synth) return;
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utterance.rate = options?.rate ?? 0.9; // Slightly slower for children's story cadence
|
||||||
|
utterance.pitch = options?.pitch ?? 1.05; // Slightly warmer/friendly pitch
|
||||||
|
|
||||||
|
// Find warm friendly voice
|
||||||
|
const voices = this.synth.getVoices();
|
||||||
|
const naturalVoice = voices.find(v =>
|
||||||
|
v.lang.startsWith('en') &&
|
||||||
|
(v.name.includes('Natural') || v.name.includes('Samantha') || v.name.includes('Karen') || v.name.includes('Daniel') || v.name.includes('Google'))
|
||||||
|
) || voices.find(v => v.lang.startsWith('en'));
|
||||||
|
|
||||||
|
if (naturalVoice) {
|
||||||
|
utterance.voice = naturalVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onEndCallback = options?.onEnd || null;
|
||||||
|
this.onWordCallback = options?.onWord || null;
|
||||||
|
|
||||||
|
utterance.onend = () => {
|
||||||
|
this.currentUtterance = null;
|
||||||
|
if (this.onEndCallback) this.onEndCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
utterance.onerror = () => {
|
||||||
|
this.currentUtterance = null;
|
||||||
|
if (this.onEndCallback) this.onEndCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
let wordCount = 0;
|
||||||
|
utterance.onboundary = (e) => {
|
||||||
|
if (e.name === 'word') {
|
||||||
|
if (this.onWordCallback) {
|
||||||
|
this.onWordCallback(wordCount);
|
||||||
|
}
|
||||||
|
wordCount++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentUtterance = utterance;
|
||||||
|
this.synth.speak(utterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if (!this.synth) return;
|
||||||
|
this.synth.cancel();
|
||||||
|
this.currentUtterance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause() {
|
||||||
|
if (this.synth) {
|
||||||
|
this.synth.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resume() {
|
||||||
|
if (this.synth) {
|
||||||
|
this.synth.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSpeaking(): boolean {
|
||||||
|
return this.synth ? this.synth.speaking : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCurrentUtterance(): SpeechSynthesisUtterance | null {
|
||||||
|
return this.currentUtterance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const speechNarrator = new StorySpeechNarrator();
|
||||||
Reference in New Issue
Block a user