1024 lines
42 KiB
TypeScript
1024 lines
42 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { supabase } from '@/lib/supabase';
|
|
import {
|
|
Timer, Play, Pause, RotateCcw, Maximize, Minimize, Volume2, VolumeX,
|
|
Plus, Minus, Settings, Palette, Music, ChevronDown, X, Monitor,
|
|
Waves, TreePine, Sparkles, CloudRain, Sun, Moon, Fish, Mountain,
|
|
Loader2, Download, Check
|
|
} from 'lucide-react';
|
|
|
|
// ─── Sensory Backgrounds ───
|
|
const SENSORY_BACKGROUNDS = [
|
|
{
|
|
id: 'ocean',
|
|
name: 'Ocean Calm',
|
|
icon: <Waves size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662475079_dabe421c.png',
|
|
overlay: 'from-blue-950/40 via-blue-900/20 to-cyan-950/40',
|
|
textColor: 'text-cyan-100',
|
|
accentColor: 'text-cyan-300',
|
|
ringColor: 'stroke-cyan-400',
|
|
trackColor: 'stroke-cyan-900/50',
|
|
animation: 'animate-ocean',
|
|
},
|
|
{
|
|
id: 'aurora',
|
|
name: 'Aurora Borealis',
|
|
icon: <Sparkles size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662490787_3aabfbd4.jpg',
|
|
overlay: 'from-purple-950/40 via-emerald-950/20 to-indigo-950/40',
|
|
textColor: 'text-emerald-100',
|
|
accentColor: 'text-emerald-300',
|
|
ringColor: 'stroke-emerald-400',
|
|
trackColor: 'stroke-emerald-900/50',
|
|
animation: 'animate-aurora',
|
|
},
|
|
{
|
|
id: 'lava',
|
|
name: 'Lava Lamp',
|
|
icon: <Sun size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662521371_be5368c4.png',
|
|
overlay: 'from-orange-950/40 via-red-950/20 to-purple-950/40',
|
|
textColor: 'text-orange-100',
|
|
accentColor: 'text-orange-300',
|
|
ringColor: 'stroke-orange-400',
|
|
trackColor: 'stroke-orange-900/50',
|
|
animation: 'animate-lava',
|
|
},
|
|
{
|
|
id: 'galaxy',
|
|
name: 'Deep Galaxy',
|
|
icon: <Moon size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662574948_03baf94b.png',
|
|
overlay: 'from-indigo-950/40 via-purple-950/20 to-blue-950/40',
|
|
textColor: 'text-purple-100',
|
|
accentColor: 'text-purple-300',
|
|
ringColor: 'stroke-purple-400',
|
|
trackColor: 'stroke-purple-900/50',
|
|
animation: 'animate-galaxy',
|
|
},
|
|
{
|
|
id: 'forest',
|
|
name: 'Enchanted Forest',
|
|
icon: <TreePine size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662588347_123efdd7.jpg',
|
|
overlay: 'from-green-950/40 via-emerald-950/20 to-green-950/40',
|
|
textColor: 'text-green-100',
|
|
accentColor: 'text-green-300',
|
|
ringColor: 'stroke-green-400',
|
|
trackColor: 'stroke-green-900/50',
|
|
animation: 'animate-forest',
|
|
},
|
|
{
|
|
id: 'rain',
|
|
name: 'Rainy Day',
|
|
icon: <CloudRain size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662620449_93a5fbcd.png',
|
|
overlay: 'from-slate-950/40 via-blue-950/20 to-slate-950/40',
|
|
textColor: 'text-blue-100',
|
|
accentColor: 'text-blue-300',
|
|
ringColor: 'stroke-blue-400',
|
|
trackColor: 'stroke-blue-900/50',
|
|
animation: 'animate-rain',
|
|
},
|
|
{
|
|
id: 'coral',
|
|
name: 'Coral Reef',
|
|
icon: <Fish size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662661000_e74a933f.png',
|
|
overlay: 'from-teal-950/40 via-cyan-950/20 to-teal-950/40',
|
|
textColor: 'text-teal-100',
|
|
accentColor: 'text-teal-300',
|
|
ringColor: 'stroke-teal-400',
|
|
trackColor: 'stroke-teal-900/50',
|
|
animation: 'animate-coral',
|
|
},
|
|
{
|
|
id: 'sunset',
|
|
name: 'Sunset Sky',
|
|
icon: <Mountain size={18} />,
|
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662744948_e27daa4c.png',
|
|
overlay: 'from-rose-950/40 via-amber-950/20 to-purple-950/40',
|
|
textColor: 'text-rose-100',
|
|
accentColor: 'text-rose-300',
|
|
ringColor: 'stroke-rose-400',
|
|
trackColor: 'stroke-rose-900/50',
|
|
animation: 'animate-sunset',
|
|
},
|
|
];
|
|
|
|
// ─── Sound Options ───
|
|
const SOUND_OPTIONS = [
|
|
{ id: 'gentle-chime', name: 'Gentle Chime', icon: '🔔', frequency: 523.25, type: 'chime' as const },
|
|
{ id: 'soft-bell', name: 'Soft Bell', icon: '🛎', frequency: 440, type: 'bell' as const },
|
|
{ id: 'xylophone', name: 'Xylophone', icon: '🎵', frequency: 659.25, type: 'xylophone' as const },
|
|
{ id: 'singing-bowl', name: 'Singing Bowl', icon: '🎶', frequency: 256, type: 'bowl' as const },
|
|
{ id: 'nature-birds', name: 'Nature Birds', icon: '🐦', frequency: 880, type: 'birds' as const },
|
|
{ id: 'ocean-wave', name: 'Ocean Wave', icon: '🌊', frequency: 200, type: 'wave' as const },
|
|
{ id: 'rain-stick', name: 'Rain Stick', icon: '🌧', frequency: 300, type: 'rain' as const },
|
|
{ id: 'harp-gliss', name: 'Harp Glissando', icon: '🎵', frequency: 392, type: 'harp' as const },
|
|
];
|
|
|
|
// ─── Preset Times ───
|
|
const PRESET_TIMES = [
|
|
{ label: '30s', seconds: 30 },
|
|
{ label: '1 min', seconds: 60 },
|
|
{ label: '2 min', seconds: 120 },
|
|
{ label: '3 min', seconds: 180 },
|
|
{ label: '5 min', seconds: 300 },
|
|
{ label: '10 min', seconds: 600 },
|
|
{ label: '15 min', seconds: 900 },
|
|
{ label: '20 min', seconds: 1200 },
|
|
{ label: '25 min', seconds: 1500 },
|
|
{ label: '30 min', seconds: 1800 },
|
|
];
|
|
|
|
// ─── Web Audio Sound Generator ───
|
|
function playBuiltInSound(soundType: string, audioCtx: AudioContext) {
|
|
const now = audioCtx.currentTime;
|
|
|
|
switch (soundType) {
|
|
case 'gentle-chime': {
|
|
const freqs = [523.25, 659.25, 783.99, 1046.5];
|
|
freqs.forEach((freq, i) => {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(0, now + i * 0.3);
|
|
gain.gain.linearRampToValueAtTime(0.3, now + i * 0.3 + 0.05);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.3 + 1.5);
|
|
osc.connect(gain).connect(audioCtx.destination);
|
|
osc.start(now + i * 0.3);
|
|
osc.stop(now + i * 0.3 + 1.5);
|
|
});
|
|
break;
|
|
}
|
|
case 'soft-bell': {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = 440;
|
|
gain.gain.setValueAtTime(0.4, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 3);
|
|
osc.connect(gain).connect(audioCtx.destination);
|
|
osc.start(now);
|
|
osc.stop(now + 3);
|
|
// Overtone
|
|
const osc2 = audioCtx.createOscillator();
|
|
const gain2 = audioCtx.createGain();
|
|
osc2.type = 'sine';
|
|
osc2.frequency.value = 880;
|
|
gain2.gain.setValueAtTime(0.15, now);
|
|
gain2.gain.exponentialRampToValueAtTime(0.001, now + 2);
|
|
osc2.connect(gain2).connect(audioCtx.destination);
|
|
osc2.start(now);
|
|
osc2.stop(now + 2);
|
|
break;
|
|
}
|
|
case 'xylophone': {
|
|
const notes = [523.25, 587.33, 659.25, 783.99, 880, 783.99, 659.25];
|
|
notes.forEach((freq, i) => {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = 'triangle';
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(0, now + i * 0.15);
|
|
gain.gain.linearRampToValueAtTime(0.35, now + i * 0.15 + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.15 + 0.6);
|
|
osc.connect(gain).connect(audioCtx.destination);
|
|
osc.start(now + i * 0.15);
|
|
osc.stop(now + i * 0.15 + 0.6);
|
|
});
|
|
break;
|
|
}
|
|
case 'singing-bowl': {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = 256;
|
|
osc.frequency.linearRampToValueAtTime(260, now + 4);
|
|
gain.gain.setValueAtTime(0, now);
|
|
gain.gain.linearRampToValueAtTime(0.35, now + 0.3);
|
|
gain.gain.setValueAtTime(0.35, now + 1);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 5);
|
|
osc.connect(gain).connect(audioCtx.destination);
|
|
osc.start(now);
|
|
osc.stop(now + 5);
|
|
// Harmonic
|
|
const osc2 = audioCtx.createOscillator();
|
|
const gain2 = audioCtx.createGain();
|
|
osc2.type = 'sine';
|
|
osc2.frequency.value = 512;
|
|
gain2.gain.setValueAtTime(0, now);
|
|
gain2.gain.linearRampToValueAtTime(0.1, now + 0.3);
|
|
gain2.gain.exponentialRampToValueAtTime(0.001, now + 4);
|
|
osc2.connect(gain2).connect(audioCtx.destination);
|
|
osc2.start(now);
|
|
osc2.stop(now + 4);
|
|
break;
|
|
}
|
|
case 'harp-gliss': {
|
|
const scale = [261.63, 293.66, 329.63, 349.23, 392, 440, 493.88, 523.25, 587.33, 659.25, 698.46, 783.99];
|
|
scale.forEach((freq, i) => {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(0, now + i * 0.08);
|
|
gain.gain.linearRampToValueAtTime(0.2, now + i * 0.08 + 0.02);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 1.2);
|
|
osc.connect(gain).connect(audioCtx.destination);
|
|
osc.start(now + i * 0.08);
|
|
osc.stop(now + i * 0.08 + 1.2);
|
|
});
|
|
break;
|
|
}
|
|
default: {
|
|
// Default gentle tone
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = 523.25;
|
|
gain.gain.setValueAtTime(0.3, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 2);
|
|
osc.connect(gain).connect(audioCtx.destination);
|
|
osc.start(now);
|
|
osc.stop(now + 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Circular Progress Component ───
|
|
const CircularProgress: React.FC<{
|
|
progress: number;
|
|
size: number;
|
|
strokeWidth: number;
|
|
ringClass: string;
|
|
trackClass: string;
|
|
}> = ({ progress, size, strokeWidth, ringClass, trackClass }) => {
|
|
const radius = (size - strokeWidth) / 2;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const offset = circumference - progress * circumference;
|
|
|
|
return (
|
|
<svg width={size} height={size} className="transform -rotate-90">
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
strokeWidth={strokeWidth}
|
|
className={trackClass}
|
|
/>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
className={`${ringClass} transition-all duration-1000 ease-linear`}
|
|
/>
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// ─── Main Component ───
|
|
const ClassroomTimer: React.FC = () => {
|
|
const [totalSeconds, setTotalSeconds] = useState(300); // 5 min default
|
|
const [remainingSeconds, setRemainingSeconds] = useState(300);
|
|
const [isRunning, setIsRunning] = useState(false);
|
|
const [isFinished, setIsFinished] = useState(false);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [selectedBackground, setSelectedBackground] = useState(SENSORY_BACKGROUNDS[0]);
|
|
const [selectedSound, setSelectedSound] = useState(SOUND_OPTIONS[0]);
|
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [showBackgrounds, setShowBackgrounds] = useState(false);
|
|
const [showSounds, setShowSounds] = useState(false);
|
|
const [customMinutes, setCustomMinutes] = useState(5);
|
|
const [customSeconds, setCustomSeconds] = useState(0);
|
|
const [isLoadingAiSound, setIsLoadingAiSound] = useState(false);
|
|
const [aiSoundUrl, setAiSoundUrl] = useState<string | null>(null);
|
|
const [useAiSound, setUseAiSound] = useState(false);
|
|
const [aiSoundCached, setAiSoundCached] = useState<Record<string, string>>({});
|
|
const [previewPlaying, setPreviewPlaying] = useState(false);
|
|
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
|
const fullscreenRef = useRef<HTMLDivElement>(null);
|
|
const aiAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
// Initialize AudioContext on first user interaction
|
|
const getAudioContext = useCallback(() => {
|
|
if (!audioCtxRef.current) {
|
|
audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
}
|
|
if (audioCtxRef.current.state === 'suspended') {
|
|
audioCtxRef.current.resume();
|
|
}
|
|
return audioCtxRef.current;
|
|
}, []);
|
|
|
|
// Timer logic
|
|
useEffect(() => {
|
|
if (isRunning && remainingSeconds > 0) {
|
|
intervalRef.current = setInterval(() => {
|
|
setRemainingSeconds(prev => {
|
|
if (prev <= 1) {
|
|
setIsRunning(false);
|
|
setIsFinished(true);
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
}
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [isRunning, remainingSeconds]);
|
|
|
|
// Play sound when finished
|
|
useEffect(() => {
|
|
if (isFinished && soundEnabled) {
|
|
if (useAiSound && aiSoundUrl) {
|
|
const audio = new Audio(aiSoundUrl);
|
|
audio.play().catch(() => {});
|
|
} else {
|
|
const ctx = getAudioContext();
|
|
playBuiltInSound(selectedSound.id, ctx);
|
|
// Play again after 3 seconds for emphasis
|
|
setTimeout(() => {
|
|
playBuiltInSound(selectedSound.id, ctx);
|
|
}, 3000);
|
|
}
|
|
}
|
|
}, [isFinished]);
|
|
|
|
// Format time
|
|
const formatTime = (secs: number) => {
|
|
const m = Math.floor(secs / 60);
|
|
const s = secs % 60;
|
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const progress = totalSeconds > 0 ? remainingSeconds / totalSeconds : 0;
|
|
|
|
const handleStart = () => {
|
|
if (remainingSeconds <= 0) return;
|
|
getAudioContext(); // Ensure audio context is ready
|
|
setIsRunning(true);
|
|
setIsFinished(false);
|
|
};
|
|
|
|
const handlePause = () => {
|
|
setIsRunning(false);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setIsRunning(false);
|
|
setIsFinished(false);
|
|
setRemainingSeconds(totalSeconds);
|
|
};
|
|
|
|
const handleSetTime = (seconds: number) => {
|
|
setIsRunning(false);
|
|
setIsFinished(false);
|
|
setTotalSeconds(seconds);
|
|
setRemainingSeconds(seconds);
|
|
};
|
|
|
|
const handleCustomTime = () => {
|
|
const total = customMinutes * 60 + customSeconds;
|
|
if (total > 0) {
|
|
handleSetTime(total);
|
|
}
|
|
};
|
|
|
|
// Fullscreen toggle
|
|
const toggleFullscreen = async () => {
|
|
if (!isFullscreen) {
|
|
try {
|
|
if (fullscreenRef.current) {
|
|
await fullscreenRef.current.requestFullscreen();
|
|
}
|
|
} catch {
|
|
// Fallscreen fallback
|
|
}
|
|
setIsFullscreen(true);
|
|
} else {
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
setIsFullscreen(false);
|
|
}
|
|
};
|
|
|
|
// Listen for fullscreen exit
|
|
useEffect(() => {
|
|
const handleFsChange = () => {
|
|
if (!document.fullscreenElement) {
|
|
setIsFullscreen(false);
|
|
}
|
|
};
|
|
document.addEventListener('fullscreenchange', handleFsChange);
|
|
return () => document.removeEventListener('fullscreenchange', handleFsChange);
|
|
}, []);
|
|
|
|
// Generate AI sound
|
|
const generateAiSound = async (soundId: string) => {
|
|
if (aiSoundCached[soundId]) {
|
|
setAiSoundUrl(aiSoundCached[soundId]);
|
|
setUseAiSound(true);
|
|
return;
|
|
}
|
|
setIsLoadingAiSound(true);
|
|
try {
|
|
const response = await supabase.functions.invoke('generate-timer-sound', {
|
|
body: { soundType: soundId },
|
|
});
|
|
if (response.error) throw response.error;
|
|
|
|
// response.data is already a Blob or we need to handle it
|
|
let blob: Blob;
|
|
if (response.data instanceof Blob) {
|
|
blob = response.data;
|
|
} else {
|
|
// If it's not a blob, try to create one
|
|
blob = new Blob([response.data], { type: 'audio/mpeg' });
|
|
}
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
setAiSoundUrl(url);
|
|
setAiSoundCached(prev => ({ ...prev, [soundId]: url }));
|
|
setUseAiSound(true);
|
|
} catch (err) {
|
|
console.error('Failed to generate AI sound:', err);
|
|
setUseAiSound(false);
|
|
} finally {
|
|
setIsLoadingAiSound(false);
|
|
}
|
|
};
|
|
|
|
// Preview sound
|
|
const previewSound = (soundId: string) => {
|
|
if (previewPlaying) return;
|
|
setPreviewPlaying(true);
|
|
|
|
if (useAiSound && aiSoundCached[soundId]) {
|
|
const audio = new Audio(aiSoundCached[soundId]);
|
|
audio.play().catch(() => {});
|
|
audio.onended = () => setPreviewPlaying(false);
|
|
setTimeout(() => setPreviewPlaying(false), 3000);
|
|
} else {
|
|
const ctx = getAudioContext();
|
|
playBuiltInSound(soundId, ctx);
|
|
setTimeout(() => setPreviewPlaying(false), 2000);
|
|
}
|
|
};
|
|
|
|
// Get urgency color based on remaining time
|
|
const getUrgencyColor = () => {
|
|
if (isFinished) return 'text-red-400 animate-pulse';
|
|
if (progress <= 0.1) return 'text-red-300';
|
|
if (progress <= 0.25) return 'text-amber-300';
|
|
return selectedBackground.textColor;
|
|
};
|
|
|
|
// ─── Fullscreen / Projection View ───
|
|
const renderProjectionView = () => (
|
|
<div
|
|
ref={fullscreenRef}
|
|
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center cursor-pointer select-none"
|
|
style={{ backgroundColor: '#000' }}
|
|
>
|
|
{/* Background Image */}
|
|
<img
|
|
src={selectedBackground.image}
|
|
alt=""
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
style={{ filter: isFinished ? 'brightness(0.3)' : 'brightness(0.7)' }}
|
|
/>
|
|
{/* Gradient Overlay */}
|
|
<div className={`absolute inset-0 bg-gradient-to-br ${selectedBackground.overlay}`} />
|
|
|
|
{/* Animated overlay effects */}
|
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
{/* Floating particles */}
|
|
{[...Array(20)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute rounded-full bg-white/10 animate-float"
|
|
style={{
|
|
width: `${Math.random() * 8 + 4}px`,
|
|
height: `${Math.random() * 8 + 4}px`,
|
|
left: `${Math.random() * 100}%`,
|
|
top: `${Math.random() * 100}%`,
|
|
animationDelay: `${Math.random() * 10}s`,
|
|
animationDuration: `${Math.random() * 10 + 10}s`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Timer Content */}
|
|
<div className="relative z-10 flex flex-col items-center gap-8">
|
|
{/* Circular Timer */}
|
|
<div className="relative">
|
|
<CircularProgress
|
|
progress={progress}
|
|
size={Math.min(window.innerWidth * 0.4, 500)}
|
|
strokeWidth={12}
|
|
ringClass={selectedBackground.ringColor}
|
|
trackClass={selectedBackground.trackColor}
|
|
/>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span
|
|
className={`font-mono font-bold tracking-wider ${getUrgencyColor()}`}
|
|
style={{ fontSize: `${Math.min(window.innerWidth * 0.1, 120)}px` }}
|
|
>
|
|
{formatTime(remainingSeconds)}
|
|
</span>
|
|
{isFinished && (
|
|
<span className={`text-2xl md:text-4xl font-bold ${selectedBackground.accentColor} animate-bounce mt-2`}>
|
|
Time's Up!
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex items-center gap-4">
|
|
{!isRunning && !isFinished && (
|
|
<button
|
|
onClick={handleStart}
|
|
className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center hover:bg-white/30 transition-all shadow-2xl"
|
|
>
|
|
<Play size={32} className="text-white ml-1" />
|
|
</button>
|
|
)}
|
|
{isRunning && (
|
|
<button
|
|
onClick={handlePause}
|
|
className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center hover:bg-white/30 transition-all shadow-2xl"
|
|
>
|
|
<Pause size={32} className="text-white" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleReset}
|
|
className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all"
|
|
>
|
|
<RotateCcw size={24} className="text-white/80" />
|
|
</button>
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all"
|
|
>
|
|
<Minimize size={24} className="text-white/80" />
|
|
</button>
|
|
<button
|
|
onClick={() => setSoundEnabled(!soundEnabled)}
|
|
className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all"
|
|
>
|
|
{soundEnabled ? <Volume2 size={24} className="text-white/80" /> : <VolumeX size={24} className="text-white/80" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Preset quick buttons in fullscreen */}
|
|
<div className="flex flex-wrap items-center justify-center gap-2 mt-2">
|
|
{PRESET_TIMES.slice(0, 6).map(p => (
|
|
<button
|
|
key={p.seconds}
|
|
onClick={() => handleSetTime(p.seconds)}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium backdrop-blur-md border transition-all ${
|
|
totalSeconds === p.seconds
|
|
? 'bg-white/25 border-white/40 text-white'
|
|
: 'bg-white/10 border-white/15 text-white/60 hover:bg-white/20 hover:text-white'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── Normal View ───
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* CSS for animations */}
|
|
<style>{`
|
|
@keyframes float {
|
|
0%, 100% { transform: translateY(0) translateX(0); opacity: 0.3; }
|
|
25% { transform: translateY(-30px) translateX(10px); opacity: 0.6; }
|
|
50% { transform: translateY(-15px) translateX(-10px); opacity: 0.4; }
|
|
75% { transform: translateY(-40px) translateX(5px); opacity: 0.5; }
|
|
}
|
|
.animate-float { animation: float 15s ease-in-out infinite; }
|
|
@keyframes pulse-glow {
|
|
0%, 100% { box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); }
|
|
50% { box-shadow: 0 0 60px rgba(139, 92, 246, 0.6); }
|
|
}
|
|
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
|
@keyframes shimmer {
|
|
0% { background-position: -200% 0; }
|
|
100% { background-position: 200% 0; }
|
|
}
|
|
.animate-shimmer {
|
|
background-size: 200% 100%;
|
|
animation: shimmer 3s linear infinite;
|
|
}
|
|
`}</style>
|
|
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shadow-lg shadow-cyan-500/30">
|
|
<Timer size={20} className="text-white" />
|
|
</div>
|
|
Classroom Timer
|
|
</h2>
|
|
<p className="text-sm text-slate-400 mt-1">
|
|
Visual countdown timer with sensory backgrounds — perfect for transitions, work time, and breaks. Project on the big screen!
|
|
</p>
|
|
</div>
|
|
|
|
{/* Main Timer Area */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Timer Display */}
|
|
<div className="lg:col-span-2">
|
|
<div className="relative overflow-hidden rounded-2xl border border-slate-700/40 shadow-2xl shadow-black/30" style={{ minHeight: '480px' }}>
|
|
{/* Background */}
|
|
<img
|
|
src={selectedBackground.image}
|
|
alt=""
|
|
className="absolute inset-0 w-full h-full object-cover transition-all duration-1000"
|
|
style={{ filter: isFinished ? 'brightness(0.3) saturate(0.5)' : 'brightness(0.6)' }}
|
|
/>
|
|
<div className={`absolute inset-0 bg-gradient-to-br ${selectedBackground.overlay} transition-all duration-1000`} />
|
|
|
|
{/* Floating particles */}
|
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
{[...Array(12)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute rounded-full bg-white/10 animate-float"
|
|
style={{
|
|
width: `${Math.random() * 6 + 3}px`,
|
|
height: `${Math.random() * 6 + 3}px`,
|
|
left: `${Math.random() * 100}%`,
|
|
top: `${Math.random() * 100}%`,
|
|
animationDelay: `${Math.random() * 8}s`,
|
|
animationDuration: `${Math.random() * 8 + 8}s`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Timer Content */}
|
|
<div className="relative z-10 flex flex-col items-center justify-center h-full py-10 px-6">
|
|
{/* Circular Timer */}
|
|
<div className="relative">
|
|
<CircularProgress
|
|
progress={progress}
|
|
size={280}
|
|
strokeWidth={8}
|
|
ringClass={selectedBackground.ringColor}
|
|
trackClass={selectedBackground.trackColor}
|
|
/>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span className={`font-mono text-7xl md:text-8xl font-bold tracking-wider ${getUrgencyColor()}`}>
|
|
{formatTime(remainingSeconds)}
|
|
</span>
|
|
{isFinished && (
|
|
<span className={`text-xl font-bold ${selectedBackground.accentColor} animate-bounce mt-1`}>
|
|
Time's Up!
|
|
</span>
|
|
)}
|
|
{!isRunning && !isFinished && remainingSeconds === totalSeconds && (
|
|
<span className={`text-sm ${selectedBackground.accentColor} opacity-60 mt-1`}>
|
|
Ready
|
|
</span>
|
|
)}
|
|
{isRunning && (
|
|
<span className={`text-sm ${selectedBackground.accentColor} opacity-60 mt-1`}>
|
|
Running...
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex items-center gap-3 mt-8">
|
|
{!isRunning && !isFinished && (
|
|
<button
|
|
onClick={handleStart}
|
|
disabled={remainingSeconds <= 0}
|
|
className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center hover:bg-white/30 transition-all shadow-xl hover:scale-105 active:scale-95 disabled:opacity-30"
|
|
>
|
|
<Play size={26} className="text-white ml-0.5" />
|
|
</button>
|
|
)}
|
|
{isRunning && (
|
|
<button
|
|
onClick={handlePause}
|
|
className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center hover:bg-white/30 transition-all shadow-xl hover:scale-105 active:scale-95"
|
|
>
|
|
<Pause size={26} className="text-white" />
|
|
</button>
|
|
)}
|
|
{(isFinished || (!isRunning && remainingSeconds < totalSeconds)) && (
|
|
<button
|
|
onClick={handleStart}
|
|
className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center hover:bg-white/30 transition-all shadow-xl hover:scale-105 active:scale-95"
|
|
>
|
|
<Play size={26} className="text-white ml-0.5" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleReset}
|
|
className="w-11 h-11 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all hover:scale-105 active:scale-95"
|
|
>
|
|
<RotateCcw size={20} className="text-white/80" />
|
|
</button>
|
|
<button
|
|
onClick={() => setSoundEnabled(!soundEnabled)}
|
|
className="w-11 h-11 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all hover:scale-105 active:scale-95"
|
|
>
|
|
{soundEnabled ? <Volume2 size={20} className="text-white/80" /> : <VolumeX size={20} className="text-white/80" />}
|
|
</button>
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="w-11 h-11 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all hover:scale-105 active:scale-95"
|
|
title="Fullscreen — Project on large screen"
|
|
>
|
|
<Maximize size={20} className="text-white/80" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Quick presets row */}
|
|
<div className="flex flex-wrap items-center justify-center gap-2 mt-6">
|
|
{PRESET_TIMES.map(p => (
|
|
<button
|
|
key={p.seconds}
|
|
onClick={() => handleSetTime(p.seconds)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-medium backdrop-blur-md border transition-all ${
|
|
totalSeconds === p.seconds
|
|
? 'bg-white/25 border-white/40 text-white shadow-lg'
|
|
: 'bg-white/8 border-white/15 text-white/50 hover:bg-white/15 hover:text-white/80'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fullscreen hint */}
|
|
<div className="absolute bottom-3 right-3 z-10">
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-black/40 backdrop-blur-md rounded-lg text-white/60 text-xs hover:text-white hover:bg-black/60 transition-all border border-white/10"
|
|
>
|
|
<Monitor size={14} />
|
|
Project on Screen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Settings Panel */}
|
|
<div className="space-y-4">
|
|
{/* Custom Time */}
|
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
|
<h3 className="font-semibold text-white text-sm flex items-center gap-2 mb-4">
|
|
<Settings size={16} className="text-cyan-400" />
|
|
Custom Time
|
|
</h3>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className="flex-1">
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wider mb-1 block">Minutes</label>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setCustomMinutes(Math.max(0, customMinutes - 1))}
|
|
className="w-8 h-8 rounded-lg bg-slate-700/50 border border-slate-600/50 flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
|
>
|
|
<Minus size={14} />
|
|
</button>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={120}
|
|
value={customMinutes}
|
|
onChange={e => setCustomMinutes(Math.max(0, Math.min(120, parseInt(e.target.value) || 0)))}
|
|
className="w-14 h-8 bg-slate-700/50 border border-slate-600/50 rounded-lg text-center text-white text-sm font-mono focus:ring-2 focus:ring-cyan-500/50 outline-none"
|
|
/>
|
|
<button
|
|
onClick={() => setCustomMinutes(Math.min(120, customMinutes + 1))}
|
|
className="w-8 h-8 rounded-lg bg-slate-700/50 border border-slate-600/50 flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<span className="text-2xl text-slate-600 font-light mt-4">:</span>
|
|
<div className="flex-1">
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wider mb-1 block">Seconds</label>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setCustomSeconds(Math.max(0, customSeconds - 5))}
|
|
className="w-8 h-8 rounded-lg bg-slate-700/50 border border-slate-600/50 flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
|
>
|
|
<Minus size={14} />
|
|
</button>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={59}
|
|
value={customSeconds}
|
|
onChange={e => setCustomSeconds(Math.max(0, Math.min(59, parseInt(e.target.value) || 0)))}
|
|
className="w-14 h-8 bg-slate-700/50 border border-slate-600/50 rounded-lg text-center text-white text-sm font-mono focus:ring-2 focus:ring-cyan-500/50 outline-none"
|
|
/>
|
|
<button
|
|
onClick={() => setCustomSeconds(Math.min(59, customSeconds + 5))}
|
|
className="w-8 h-8 rounded-lg bg-slate-700/50 border border-slate-600/50 flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleCustomTime}
|
|
className="w-full py-2 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-600 hover:to-blue-600 text-white font-semibold rounded-xl text-sm transition-all shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40"
|
|
>
|
|
Set Timer
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sound Selection */}
|
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
|
<h3 className="font-semibold text-white text-sm flex items-center gap-2 mb-3">
|
|
<Music size={16} className="text-violet-400" />
|
|
Timer Sound
|
|
</h3>
|
|
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
|
|
{SOUND_OPTIONS.map(sound => (
|
|
<button
|
|
key={sound.id}
|
|
onClick={() => {
|
|
setSelectedSound(sound);
|
|
setUseAiSound(false);
|
|
}}
|
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-all ${
|
|
selectedSound.id === sound.id && !useAiSound
|
|
? 'bg-violet-500/15 text-white border border-violet-500/30'
|
|
: 'text-slate-400 hover:bg-slate-700/50 hover:text-white border border-transparent'
|
|
}`}
|
|
>
|
|
<span className="text-base w-6 text-center">{sound.icon}</span>
|
|
<span className="flex-1 text-left font-medium">{sound.name}</span>
|
|
{selectedSound.id === sound.id && !useAiSound && (
|
|
<Check size={14} className="text-violet-400" />
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
previewSound(sound.id);
|
|
}}
|
|
className="w-7 h-7 rounded-lg bg-slate-700/50 flex items-center justify-center hover:bg-slate-600/50 transition-colors"
|
|
title="Preview sound"
|
|
>
|
|
<Volume2 size={12} className="text-slate-400" />
|
|
</button>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* AI Sound Generation */}
|
|
<div className="mt-3 pt-3 border-t border-slate-700/40">
|
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1">
|
|
<Sparkles size={10} />
|
|
AI-Generated Sound
|
|
</p>
|
|
<button
|
|
onClick={() => generateAiSound(selectedSound.id)}
|
|
disabled={isLoadingAiSound}
|
|
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-medium transition-all border ${
|
|
useAiSound
|
|
? 'bg-gradient-to-r from-violet-500/20 to-purple-500/20 text-violet-300 border-violet-500/30'
|
|
: 'bg-slate-700/30 text-slate-400 border-slate-600/30 hover:bg-slate-700/50 hover:text-white'
|
|
}`}
|
|
>
|
|
{isLoadingAiSound ? (
|
|
<>
|
|
<Loader2 size={14} className="animate-spin" />
|
|
Generating...
|
|
</>
|
|
) : useAiSound ? (
|
|
<>
|
|
<Check size={14} />
|
|
AI Sound Active
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download size={14} />
|
|
Generate AI "{selectedSound.name}"
|
|
</>
|
|
)}
|
|
</button>
|
|
{useAiSound && (
|
|
<button
|
|
onClick={() => setUseAiSound(false)}
|
|
className="w-full mt-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
|
>
|
|
Switch back to built-in sound
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Background Selection */}
|
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
|
<h3 className="font-semibold text-white text-sm flex items-center gap-2 mb-3">
|
|
<Palette size={16} className="text-amber-400" />
|
|
Sensory Background
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{SENSORY_BACKGROUNDS.map(bg => (
|
|
<button
|
|
key={bg.id}
|
|
onClick={() => setSelectedBackground(bg)}
|
|
className={`relative overflow-hidden rounded-xl border-2 transition-all hover:scale-[1.03] active:scale-[0.98] ${
|
|
selectedBackground.id === bg.id
|
|
? 'border-white/50 shadow-lg ring-2 ring-white/20'
|
|
: 'border-slate-700/40 hover:border-slate-600/60'
|
|
}`}
|
|
>
|
|
<img src={bg.image} alt={bg.name} className="w-full h-16 object-cover" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
|
<div className="absolute bottom-0 left-0 right-0 p-1.5 flex items-center gap-1">
|
|
<span className="text-white/80">{bg.icon}</span>
|
|
<span className="text-[10px] text-white/80 font-medium truncate">{bg.name}</span>
|
|
</div>
|
|
{selectedBackground.id === bg.id && (
|
|
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center">
|
|
<Check size={10} className="text-white" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projection Button */}
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="w-full flex items-center justify-center gap-2 py-3.5 bg-gradient-to-r from-violet-500 to-amber-500 hover:from-violet-600 hover:to-amber-600 text-white font-bold rounded-2xl transition-all shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 text-sm"
|
|
>
|
|
<Monitor size={18} />
|
|
Project on Large Screen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tips Section */}
|
|
<div className="bg-gradient-to-r from-cyan-500/10 via-blue-500/10 to-cyan-500/5 rounded-2xl border border-cyan-500/20 p-5">
|
|
<h3 className="font-bold text-cyan-400 text-sm mb-3 flex items-center gap-2">
|
|
<Sparkles size={16} />
|
|
Timer Tips for Autism-Focused Classrooms
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-white">Transitions</h4>
|
|
<p className="text-xs text-slate-400">Use 5-3-1 minute warnings before activity changes. The visual countdown reduces anxiety about unexpected transitions.</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-white">Sensory Backgrounds</h4>
|
|
<p className="text-xs text-slate-400">Project calming backgrounds during work time or sensory breaks. Ocean and forest themes are great for de-escalation.</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-white">Sound Choices</h4>
|
|
<p className="text-xs text-slate-400">Choose gentle sounds — avoid startling tones. Singing bowl and gentle chime work well for students sensitive to sudden sounds.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fullscreen View Portal */}
|
|
{isFullscreen && renderProjectionView()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ClassroomTimer;
|