285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
import {
|
|
Check,
|
|
CloudRain,
|
|
Fish,
|
|
Minus,
|
|
Monitor,
|
|
Moon,
|
|
Mountain,
|
|
Music,
|
|
Palette,
|
|
Plus,
|
|
Settings,
|
|
Sparkles,
|
|
Loader2,
|
|
Sun,
|
|
Trash2,
|
|
TreePine,
|
|
Volume2,
|
|
Wand2,
|
|
Waves,
|
|
} from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
import type { ClassroomTimerActions, ClassroomTimerState } from '@/components/classroom-timer/types';
|
|
import type { SensoryBackgroundIconId } from '@/shared/types/classroomTimer';
|
|
import { TIMER_SOUND_GENERATED_MESSAGE } from '@/shared/constants/classroomTimer';
|
|
|
|
const BACKGROUND_ICONS: Record<SensoryBackgroundIconId, LucideIcon> = {
|
|
waves: Waves,
|
|
sparkles: Sparkles,
|
|
sun: Sun,
|
|
moon: Moon,
|
|
'tree-pine': TreePine,
|
|
'cloud-rain': CloudRain,
|
|
fish: Fish,
|
|
mountain: Mountain,
|
|
};
|
|
|
|
type TimerSettingsPanelProps = {
|
|
state: ClassroomTimerState;
|
|
actions: ClassroomTimerActions;
|
|
};
|
|
|
|
export function TimerSettingsPanel({ state, actions }: TimerSettingsPanelProps) {
|
|
const {
|
|
customMinutes,
|
|
customSeconds,
|
|
selectedSound,
|
|
selectedBackground,
|
|
soundGroups,
|
|
backgrounds,
|
|
canManageAudio,
|
|
isGeneratingSound,
|
|
isDeletingSound,
|
|
} = state;
|
|
|
|
const [soundName, setSoundName] = useState('');
|
|
|
|
const handleGenerate = async () => {
|
|
await actions.generateSound(soundName);
|
|
setSoundName('');
|
|
toast.success(TIMER_SOUND_GENERATED_MESSAGE);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<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
|
|
type="button"
|
|
aria-label="Decrease minutes"
|
|
onClick={() => actions.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"
|
|
aria-label="Minutes"
|
|
min={0}
|
|
max={120}
|
|
value={customMinutes}
|
|
onChange={(event) => actions.parseCustomMinutes(event.target.value)}
|
|
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
|
|
type="button"
|
|
aria-label="Increase minutes"
|
|
onClick={() => actions.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
|
|
type="button"
|
|
aria-label="Decrease seconds"
|
|
onClick={() => actions.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"
|
|
aria-label="Seconds"
|
|
min={0}
|
|
max={59}
|
|
value={customSeconds}
|
|
onChange={(event) => actions.parseCustomSeconds(event.target.value)}
|
|
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
|
|
type="button"
|
|
aria-label="Increase seconds"
|
|
onClick={() => actions.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
|
|
type="button"
|
|
onClick={actions.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>
|
|
|
|
<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>
|
|
{canManageAudio && (
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<input
|
|
type="text"
|
|
value={soundName}
|
|
onChange={(event) => setSoundName(event.target.value)}
|
|
placeholder="Name your sound..."
|
|
aria-label="Generated sound name"
|
|
maxLength={80}
|
|
className="flex-1 min-w-0 h-9 px-3 bg-slate-700/50 border border-slate-600/50 rounded-lg text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void handleGenerate();
|
|
}}
|
|
disabled={isGeneratingSound}
|
|
className="flex items-center gap-1.5 px-3 h-9 rounded-lg bg-violet-500/15 border border-violet-500/30 text-violet-300 text-xs font-medium hover:bg-violet-500/25 hover:text-white transition-colors disabled:opacity-50 flex-shrink-0"
|
|
>
|
|
{isGeneratingSound ? <Loader2 size={13} className="animate-spin" /> : <Wand2 size={13} />}
|
|
{isGeneratingSound ? 'Generating...' : 'Generate'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="space-y-3 max-h-72 overflow-y-auto pr-1">
|
|
{soundGroups.map((group) => {
|
|
if (group.sounds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div key={group.id} className="space-y-1.5">
|
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider px-1">{group.label}</p>
|
|
{group.sounds.map((sound) => {
|
|
const isSelected = selectedSound?.key === sound.key;
|
|
const deletableId = sound.canDelete ? sound.audioFileId : null;
|
|
return (
|
|
<div
|
|
key={sound.key}
|
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-all ${
|
|
isSelected
|
|
? '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'
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => actions.setSelectedSound(sound)}
|
|
className="flex items-center gap-3 flex-1 text-left min-w-0"
|
|
aria-pressed={isSelected}
|
|
>
|
|
<span className="text-base w-6 text-center">{sound.icon}</span>
|
|
<span className="flex-1 font-medium truncate">{sound.name}</span>
|
|
{isSelected && <Check size={14} className="text-violet-400 flex-shrink-0" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => actions.previewSound(sound)}
|
|
className="w-7 h-7 rounded-lg bg-slate-700/50 flex items-center justify-center hover:bg-slate-600/50 transition-colors flex-shrink-0"
|
|
title="Preview sound"
|
|
aria-label={`Preview ${sound.name}`}
|
|
>
|
|
<Volume2 size={12} className="text-slate-400" />
|
|
</button>
|
|
{deletableId && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void actions.deleteSound(deletableId);
|
|
}}
|
|
disabled={isDeletingSound}
|
|
className="w-7 h-7 rounded-lg bg-slate-700/50 flex items-center justify-center hover:bg-red-500/20 hover:text-red-400 text-slate-400 transition-colors flex-shrink-0 disabled:opacity-50"
|
|
title="Delete sound"
|
|
aria-label={`Delete ${sound.name}`}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<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">
|
|
{backgrounds.map((background) => {
|
|
const BackgroundIcon = BACKGROUND_ICONS[background.iconId];
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={background.id}
|
|
onClick={() => actions.setSelectedBackground(background)}
|
|
className={`relative overflow-hidden rounded-xl border-2 transition-all hover:scale-[1.03] active:scale-[0.98] ${
|
|
selectedBackground?.id === background.id
|
|
? 'border-white/50 shadow-lg ring-2 ring-white/20'
|
|
: 'border-slate-700/40 hover:border-slate-600/60'
|
|
}`}
|
|
>
|
|
<img src={background.image} alt={background.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">
|
|
<BackgroundIcon size={14} className="text-white/80" />
|
|
<span className="text-[10px] text-white/80 font-medium truncate">{background.name}</span>
|
|
</div>
|
|
{selectedBackground?.id === background.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>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={actions.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>
|
|
);
|
|
}
|