import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, } from './api'; import type { VoiceChannelInfo, Sound, Category } from './types'; import { getCookie, setCookie } from './cookies'; const THEMES = [ { id: 'default', color: '#5865f2', label: 'Discord' }, { id: 'purple', color: '#9b59b6', label: 'Midnight' }, { id: 'forest', color: '#2ecc71', label: 'Forest' }, { id: 'sunset', color: '#e67e22', label: 'Sunset' }, { id: 'ocean', color: '#3498db', label: 'Ocean' }, ]; const CAT_PALETTE = [ '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', '#d946ef', '#0ea5e9', '#f43f5e', '#10b981', ]; type Tab = 'all' | 'favorites' | 'recent'; export default function App() { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); const [folders, setFolders] = useState>([]); const [categories, setCategories] = useState([]); /* ── Navigation ── */ const [activeTab, setActiveTab] = useState('all'); const [activeFolder, setActiveFolder] = useState(''); const [query, setQuery] = useState(''); /* ── Channels ── */ const [channels, setChannels] = useState([]); const [selected, setSelected] = useState(''); const selectedRef = useRef(''); const [channelOpen, setChannelOpen] = useState(false); /* ── Playback ── */ const [volume, setVolume] = useState(1); const [lastPlayed, setLastPlayed] = useState(''); /* ── Preferences ── */ const [favs, setFavs] = useState>({}); const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default'); const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110')); /* ── Party ── */ const [chaosMode, setChaosMode] = useState(false); const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); const chaosModeRef = useRef(false); /* ── Admin ── */ const [isAdmin, setIsAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [adminPwd, setAdminPwd] = useState(''); /* ── UI ── */ const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); const [clock, setClock] = useState(''); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null); const [refreshKey, setRefreshKey] = useState(0); /* ── Refs ── */ useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); useEffect(() => { selectedRef.current = selected; }, [selected]); /* ── Helpers ── */ const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { setNotification({ msg, type }); setTimeout(() => setNotification(null), 3000); }, []); const guildId = selected ? selected.split(':')[0] : ''; const channelId = selected ? selected.split(':')[1] : ''; const selectedChannel = useMemo(() => channels.find(c => `${c.guildId}:${c.channelId}` === selected), [channels, selected]); /* ── Clock ── */ useEffect(() => { const update = () => { const now = new Date(); const h = String(now.getHours()).padStart(2, '0'); const m = String(now.getMinutes()).padStart(2, '0'); const s = String(now.getSeconds()).padStart(2, '0'); setClock(`${h}:${m}:${s}`); }; update(); const id = setInterval(update, 1000); return () => clearInterval(id); }, []); /* ── Init ── */ useEffect(() => { (async () => { try { const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); setChannels(ch); if (ch.length) { const g = ch[0].guildId; const serverCid = selMap[g]; const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); } } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } try { setIsAdmin(await adminStatus()); } catch { } try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } })(); }, []); /* ── Theme ── */ useEffect(() => { if (theme === 'default') document.body.removeAttribute('data-theme'); else document.body.setAttribute('data-theme', theme); localStorage.setItem('jb-theme', theme); }, [theme]); /* ── Card size ── */ useEffect(() => { const r = document.documentElement; r.style.setProperty('--card-size', cardSize + 'px'); const ratio = cardSize / 110; r.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); r.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); localStorage.setItem('jb-card-size', String(cardSize)); }, [cardSize]); /* ── SSE ── */ useEffect(() => { const unsub = subscribeEvents((msg) => { if (msg?.type === 'party') { setPartyActiveGuilds(prev => { const s = new Set(prev); if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId); return Array.from(s); }); } else if (msg?.type === 'snapshot') { setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); try { const sel = msg?.selected || {}; const g = selectedRef.current?.split(':')[0]; if (g && sel[g]) setSelected(`${g}:${sel[g]}`); } catch { } try { const vols = msg?.volumes || {}; const g = selectedRef.current?.split(':')[0]; if (g && typeof vols[g] === 'number') setVolume(vols[g]); } catch { } } else if (msg?.type === 'channel') { const g = selectedRef.current?.split(':')[0]; if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); } else if (msg?.type === 'volume') { const g = selectedRef.current?.split(':')[0]; if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); } }); return () => { try { unsub(); } catch { } }; }, []); useEffect(() => { setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); }, [selected, partyActiveGuilds]); /* ── Data Fetch ── */ useEffect(() => { (async () => { try { let folderParam = '__all__'; if (activeTab === 'recent') folderParam = '__recent__'; else if (activeFolder) folderParam = activeFolder; const s = await fetchSounds(query, folderParam, undefined, false); setSounds(s.items); setTotal(s.total); setFolders(s.folders); } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } })(); }, [activeTab, activeFolder, query, refreshKey]); /* ── Favs persistence ── */ useEffect(() => { const c = getCookie('favs'); if (c) try { setFavs(JSON.parse(c)); } catch { } }, []); useEffect(() => { try { setCookie('favs', JSON.stringify(favs)); } catch { } }, [favs]); /* ── Volume sync ── */ useEffect(() => { if (selected) { (async () => { try { const v = await getVolume(guildId); setVolume(v); } catch { } })(); } }, [selected]); /* ── Close dropdowns on outside click ── */ useEffect(() => { const handler = () => { setChannelOpen(false); setCtxMenu(null); }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }, []); /* ── Actions ── */ async function handlePlay(s: Sound) { if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); try { await playSound(s.name, guildId, channelId, volume, s.relativePath); setLastPlayed(s.name); } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } async function handleStop() { if (!selected) return; setLastPlayed(''); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } } async function handleRandom() { if (!displaySounds.length || !selected) return; const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)]; handlePlay(rnd); } async function toggleParty() { if (chaosMode) { await handleStop(); try { await partyStop(guildId); } catch { } } else { if (!selected) return notify('Bitte einen Channel auswählen', 'error'); try { await partyStart(guildId, channelId); } catch { } } } async function handleChannelSelect(ch: VoiceChannelInfo) { const v = `${ch.guildId}:${ch.channelId}`; setSelected(v); setChannelOpen(false); try { await setSelectedChannel(ch.guildId, ch.channelId); } catch { } } function toggleFav(key: string) { setFavs(prev => ({ ...prev, [key]: !prev[key] })); } async function handleAdminLogin() { try { const ok = await adminLogin(adminPwd); if (ok) { setIsAdmin(true); setAdminPwd(''); notify('Admin eingeloggt'); } else notify('Falsches Passwort', 'error'); } catch { notify('Login fehlgeschlagen', 'error'); } } async function handleAdminLogout() { try { await adminLogout(); setIsAdmin(false); notify('Ausgeloggt'); } catch { } } /* ── Computed ── */ const displaySounds = useMemo(() => { if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); return sounds; }, [sounds, activeTab, favs]); const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); const visibleFolders = useMemo(() => folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), [folders]); const folderColorMap = useMemo(() => { const m: Record = {}; visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; }); return m; }, [visibleFolders]); const firstOfInitial = useMemo(() => { const seen = new Set(); const result = new Set(); displaySounds.forEach((s, idx) => { const ch = s.name.charAt(0).toUpperCase(); if (!seen.has(ch)) { seen.add(ch); result.add(idx); } }); return result; }, [displaySounds]); const channelsByGuild = useMemo(() => { const groups: Record = {}; channels.forEach(c => { if (!groups[c.guildName]) groups[c.guildName] = []; groups[c.guildName].push(c); }); return groups; }, [channels]); const clockMain = clock.slice(0, 5); const clockSec = clock.slice(5); /* ════════════════════════════════════════════ RENDER ════════════════════════════════════════════ */ return (
{chaosMode &&
} {/* ═══ TOPBAR ═══ */}
music_note
Soundboard {/* Channel Dropdown */}
e.stopPropagation()}> {channelOpen && (
{Object.entries(channelsByGuild).map(([guild, chs]) => (
{guild}
{chs.map(ch => (
handleChannelSelect(ch)} > volume_up {ch.channelName}
))}
))} {channels.length === 0 && (
Keine Channels verfügbar
)}
)}
{clockMain}{clockSec}
{selected && (
Verbunden
)}
{/* ═══ TOOLBAR ═══ */}
search setQuery(e.target.value)} /> {query && ( )}
{ const newVol = volume > 0 ? 0 : 0.5; setVolume(newVol); if (guildId) setVolumeLive(guildId, newVol).catch(() => {}); }} > {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} { const v = parseFloat(e.target.value); setVolume(v); if (guildId) try { await setVolumeLive(guildId, v); } catch { } }} style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} /> {Math.round(volume * 100)}%
grid_view setCardSize(parseInt(e.target.value))} />
{THEMES.map(t => (
setTheme(t.id)} /> ))}
{/* ═══ FOLDER CHIPS ═══ */} {activeTab === 'all' && visibleFolders.length > 0 && (
{visibleFolders.map(f => { const color = folderColorMap[f.key] || '#888'; const isActive = activeFolder === f.key; return ( ); })}
)} {/* ═══ MAIN ═══ */}
{displaySounds.length === 0 ? (
{activeTab === 'favorites' ? '⭐' : '🔇'}
{activeTab === 'favorites' ? 'Noch keine Favoriten' : query ? `Kein Sound für "${query}" gefunden` : 'Keine Sounds vorhanden'}
{activeTab === 'favorites' ? 'Klick den Stern auf einem Sound!' : 'Hier gibt\'s noch nichts zu hören.'}
) : (
{displaySounds.map((s, idx) => { const key = s.relativePath ?? s.fileName; const isFav = !!favs[key]; const isPlaying = lastPlayed === s.name; const isNew = s.isRecent || s.badges?.includes('new'); const initial = s.name.charAt(0).toUpperCase(); const showInitial = firstOfInitial.has(idx); const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; return (
{ const card = e.currentTarget; const rect = card.getBoundingClientRect(); const ripple = document.createElement('div'); ripple.className = 'ripple'; const sz = Math.max(rect.width, rect.height); ripple.style.width = ripple.style.height = sz + 'px'; ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; card.appendChild(ripple); setTimeout(() => ripple.remove(), 500); handlePlay(s); }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setCtxMenu({ x: Math.min(e.clientX, window.innerWidth - 170), y: Math.min(e.clientY, window.innerHeight - 140), sound: s, }); }} title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} > {isNew && NEU} { e.stopPropagation(); toggleFav(key); }} > {isFav ? 'star' : 'star_border'} {showInitial && {initial}} {s.name} {s.folder && {s.folder}}
); })}
)}
{/* ═══ BOTTOM BAR ═══ */}
Spielt: {lastPlayed || '—'}
{/* ═══ CONTEXT MENU ═══ */} {ctxMenu && (
e.stopPropagation()} >
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> play_arrow Abspielen
{ toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); setCtxMenu(null); }}> {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} Favorit
{isAdmin && ( <>
{ const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; try { await adminDelete([path]); notify('Sound gelöscht'); setRefreshKey(k => k + 1); } catch { notify('Löschen fehlgeschlagen', 'error'); } setCtxMenu(null); }}> delete Löschen
)}
)} {/* ═══ TOAST ═══ */} {notification && (
{notification.type === 'error' ? 'error_outline' : 'check_circle'} {notification.msg}
)} {/* ═══ ADMIN PANEL ═══ */} {showAdmin && (
{ if (e.target === e.currentTarget) setShowAdmin(false); }}>

Admin

{!isAdmin ? (
setAdminPwd(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} placeholder="Admin-Passwort..." />
) : (

Eingeloggt als Admin

)}
)}
); }