'use client'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { previewUISound } from '@/lib/ui-audio'; import type { UITheme, UISlotMap } from '@/lib/types'; interface UISound { path: string; filename: string; group: string; } type SlotName = 'click' | 'hover' | 'error' | 'pageChange' | 'toggle' | 'confirm'; const CANONICAL_SLOTS = new Set(['click', 'hover', 'error', 'pageChange', 'toggle', 'confirm']); const SLOTS: { name: SlotName; label: string; desc: string }[] = [ { name: 'hover', label: 'HOVER', desc: 'Mouse over interactive element' }, { name: 'click', label: 'CLICK', desc: 'Button or card clicked' }, { name: 'toggle', label: 'TOGGLE', desc: 'Expand / collapse sidebar items' }, { name: 'pageChange', label: 'PAGE CHANGE', desc: 'Tab / group navigation' }, { name: 'confirm', label: 'CONFIRM', desc: 'Sound assigned, settings saved' }, { name: 'error', label: 'ERROR', desc: 'Action failed or invalid' }, ]; interface Props { uiTheme: UITheme; uiSounds: Record; onSave: (theme: UITheme, sounds: Record) => void; onClose: () => void; } export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) { const [sounds, setSounds] = useState([]); const [activeTheme, setActiveTheme] = useState(uiTheme === 'off' ? '' : uiTheme); const [slots, setSlots] = useState(uiSounds[uiTheme === 'off' ? '' : uiTheme] ?? {}); const [activeSlot, setActiveSlot] = useState('hover'); const [playing, setPlaying] = useState(null); useEffect(() => { fetch('/api/ui-sounds').then((r) => r.json()).then(setSounds).catch(console.error); }, []); // Derive theme tabs: a ui/ subdir qualifies as a theme only if it contains // at least one file whose basename matches a canonical slot name. // Folders starting with '_' are always excluded (utility library escape hatch). const themes = useMemo(() => { const validGroups = new Set( sounds .filter((s) => { const base = s.filename.replace(/\.(mp3|wav|ogg|m4a)$/i, ''); return CANONICAL_SLOTS.has(base); }) .map((s) => s.group) ); return [...validGroups].filter((t) => t && !t.startsWith('_')).sort(); }, [sounds]); // When themes load, initialize activeTheme to first available if current isn't in list useEffect(() => { if (themes.length > 0 && activeTheme === '') { const initial = themes[0]; setActiveTheme(initial); setSlots(uiSounds[initial] ?? {}); } }, [themes, activeTheme, uiSounds]); // When theme changes, load existing slot config for that theme const switchTheme = useCallback((theme: string) => { setActiveTheme(theme); setSlots(uiSounds[theme] ?? {}); }, [uiSounds]); const handlePreview = useCallback(async (path: string) => { setPlaying(path); await previewUISound(path, 0.5); setTimeout(() => setPlaying((p) => p === path ? null : p), 800); }, []); const assignSound = useCallback((path: string) => { setSlots((prev) => ({ ...prev, [activeSlot]: path })); handlePreview(path); }, [activeSlot, handlePreview]); const clearSlot = useCallback((slot: SlotName) => { setSlots((prev) => { const next = { ...prev }; delete next[slot]; return next; }); }, []); const handleSave = useCallback(() => { const nextSounds = { ...uiSounds, [activeTheme]: slots }; onSave(activeTheme, nextSounds); onClose(); }, [uiSounds, activeTheme, slots, onSave, onClose]); // Group sounds by their group const grouped = sounds.reduce>((acc, s) => { (acc[s.group] ??= []).push(s); return acc; }, {}); const formatName = (filename: string) => filename.replace(/\.(mp3|wav|ogg|m4a)$/i, '').replace(/-/g, ' ').replace(/_/g, ' '); return (
e.target === e.currentTarget && onClose()} >
{/* Header */}
UI SFX CONFIGURATOR
{/* Theme selector */}
THEME {themes.map((t) => ( ))} Click a slot on the left, then pick a sound on the right to assign.
{/* Body: 2-col */}
{/* Left: slots */}
EVENT SLOTS
{SLOTS.map(({ name, label, desc }) => { const assigned = slots[name]; const isActive = activeSlot === name; return (
setActiveSlot(name)} className="flex flex-col gap-1 px-4 py-3 cursor-pointer transition-all" style={{ borderLeft: `3px solid ${isActive ? 'var(--sf-cyan)' : 'transparent'}`, backgroundColor: isActive ? 'rgba(0,229,255,0.06)' : 'transparent', borderBottom: '1px solid var(--sf-border)', }} >
{label}
{assigned && ( <> )}
{desc} {assigned ? ( {formatName(assigned.split('/').pop() ?? assigned)} ) : ( — unassigned (uses default) — )}
); })} {/* Default paths note */}
Defaults: ui/{activeTheme}/hover.mp3
ui/{activeTheme}/click.mp3
ui/{activeTheme}/error.mp3
{/* Right: sound browser */}
AVAILABLE SOUNDS — click to preview & assign to{' '} {activeSlot.toUpperCase()}
{Object.entries(grouped).sort().map(([group, groupSounds]) => (
{group.replace(/-/g, ' ')}
{groupSounds.map((s) => { const isAssignedHere = Object.values(slots).includes(s.path); const isPlaying = playing === s.path; return ( ); })}
))} {sounds.length === 0 && (
LOADING...
)}
{/* Footer */}
); }