import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, uploadFile, } from './api'; import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } 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'; type UploadItem = { id: string; file: File; status: 'waiting' | 'uploading' | 'done' | 'error'; progress: number; savedName?: string; error?: string; }; export default function App() { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); const [folders, setFolders] = useState>([]); const [categories, setCategories] = useState([]); const [analytics, setAnalytics] = useState({ totalSounds: 0, totalPlays: 0, mostPlayed: [], }); /* ── Navigation ── */ const [activeTab, setActiveTab] = useState('all'); const [activeFolder, setActiveFolder] = useState(''); const [query, setQuery] = useState(''); const [importUrl, setImportUrl] = useState(''); const [importBusy, setImportBusy] = useState(false); /* ── 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); const volDebounceRef = useRef>(); /* ── Admin ── */ const [isAdmin, setIsAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [adminPwd, setAdminPwd] = useState(''); const [adminSounds, setAdminSounds] = useState([]); const [adminLoading, setAdminLoading] = useState(false); const [adminQuery, setAdminQuery] = useState(''); const [adminSelection, setAdminSelection] = useState>({}); const [renameTarget, setRenameTarget] = useState(''); const [renameValue, setRenameValue] = useState(''); /* ── Drag & Drop Upload ── */ const [isDragging, setIsDragging] = useState(false); const [uploads, setUploads] = useState([]); const [showUploads, setShowUploads] = useState(false); const dragCounterRef = useRef(0); const uploadDismissRef = useRef>(); /* ── Voice Stats ── */ interface VoiceStats { voicePing: number | null; gatewayPing: number; status: string; channelName: string | null; connectedSince: string | null; } const [voiceStats, setVoiceStats] = useState(null); const [showConnModal, setShowConnModal] = useState(false); /* ── 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]); /* ── Drag & Drop: globale Window-Listener ── */ useEffect(() => { const onDragEnter = (e: DragEvent) => { if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { dragCounterRef.current++; setIsDragging(true); } }; const onDragLeave = () => { dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) setIsDragging(false); }; const onDragOver = (e: DragEvent) => e.preventDefault(); const onDrop = (e: DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragging(false); const files = Array.from(e.dataTransfer?.files ?? []).filter(f => /\.(mp3|wav)$/i.test(f.name) ); if (files.length) handleFileDrop(files); }; window.addEventListener('dragenter', onDragEnter); window.addEventListener('dragleave', onDragLeave); window.addEventListener('dragover', onDragOver); window.addEventListener('drop', onDrop); return () => { window.removeEventListener('dragenter', onDragEnter); window.removeEventListener('dragleave', onDragLeave); window.removeEventListener('dragover', onDragOver); window.removeEventListener('drop', onDrop); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAdmin]); /* ── Helpers ── */ const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { setNotification({ msg, type }); setTimeout(() => setNotification(null), 3000); }, []); const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); const isMp3Url = useCallback((value: string) => { try { const parsed = new URL(value.trim()); return parsed.pathname.toLowerCase().endsWith('.mp3'); } catch { return false; } }, []); 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 { } try { const np = msg?.nowplaying || {}; const g = selectedRef.current?.split(':')[0]; if (g && typeof np[g] === 'string') setLastPlayed(np[g]); } catch { } try { const vs = msg?.voicestats || {}; const g = selectedRef.current?.split(':')[0]; if (g && vs[g]) setVoiceStats(vs[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); } else if (msg?.type === 'nowplaying') { const g = selectedRef.current?.split(':')[0]; if (msg.guildId === g) setLastPlayed(msg.name || ''); } else if (msg?.type === 'voicestats') { const g = selectedRef.current?.split(':')[0]; if (msg.guildId === g) { setVoiceStats({ voicePing: msg.voicePing, gatewayPing: msg.gatewayPing, status: msg.status, channelName: msg.channelName, connectedSince: msg.connectedSince, }); } } }); 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]); useEffect(() => { void loadAnalytics(); }, [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); }, []); useEffect(() => { if (showAdmin && isAdmin) { void loadAdminSounds(); } }, [showAdmin, isAdmin]); /* ── Actions ── */ async function loadAnalytics() { try { const data = await fetchAnalytics(); setAnalytics(data); } catch { } } 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); void loadAnalytics(); } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } async function handleUrlImport() { const trimmed = importUrl.trim(); if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); setImportBusy(true); try { await playUrl(trimmed, guildId, channelId, volume); setImportUrl(''); notify('MP3 importiert und abgespielt'); setRefreshKey(k => k + 1); await loadAnalytics(); } catch (e: any) { notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); } finally { setImportBusy(false); } } async function handleFileDrop(files: File[]) { if (!isAdmin) { notify('Admin-Login erforderlich zum Hochladen', 'error'); return; } if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); const items: UploadItem[] = files.map(f => ({ id: Math.random().toString(36).slice(2), file: f, status: 'waiting', progress: 0, })); setUploads(items); setShowUploads(true); const updated = [...items]; for (let i = 0; i < updated.length; i++) { updated[i] = { ...updated[i], status: 'uploading' }; setUploads([...updated]); try { const savedName = await uploadFile( updated[i].file, pct => { updated[i] = { ...updated[i], progress: pct }; setUploads([...updated]); }, ); updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; } catch (e: any) { updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; } setUploads([...updated]); } // Sound-Liste aktualisieren setRefreshKey(k => k + 1); void loadAnalytics(); // Auto-Dismiss nach 3s uploadDismissRef.current = setTimeout(() => { setShowUploads(false); setUploads([]); }, 3500); } 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 loadAdminSounds() { setAdminLoading(true); try { const data = await fetchSounds('', '__all__', undefined, false); setAdminSounds(data.items || []); } catch (e: any) { notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); } finally { setAdminLoading(false); } } function toggleAdminSelection(path: string) { setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); } function startRename(sound: Sound) { setRenameTarget(soundKey(sound)); setRenameValue(sound.name); } function cancelRename() { setRenameTarget(''); setRenameValue(''); } async function submitRename() { if (!renameTarget) return; const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); if (!baseName) { notify('Bitte einen gültigen Namen eingeben', 'error'); return; } try { await adminRename(renameTarget, baseName); notify('Sound umbenannt'); cancelRename(); setRefreshKey(k => k + 1); if (showAdmin) await loadAdminSounds(); } catch (e: any) { notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); } } async function deleteAdminPaths(paths: string[]) { if (paths.length === 0) return; try { await adminDelete(paths); notify(paths.length === 1 ? 'Sound gelöscht' : `${paths.length} Sounds gelöscht`); setAdminSelection({}); cancelRename(); setRefreshKey(k => k + 1); if (showAdmin) await loadAdminSounds(); } catch (e: any) { notify(e?.message || 'Löschen fehlgeschlagen', 'error'); } } 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); setAdminSelection({}); cancelRename(); 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 adminFilteredSounds = useMemo(() => { const q = adminQuery.trim().toLowerCase(); if (!q) return adminSounds; return adminSounds.filter(s => { const key = soundKey(s).toLowerCase(); return s.name.toLowerCase().includes(q) || (s.folder || '').toLowerCase().includes(q) || key.includes(q); }); }, [adminQuery, adminSounds, soundKey]); const selectedAdminPaths = useMemo(() => Object.keys(adminSelection).filter(k => adminSelection[k]), [adminSelection]); const selectedVisibleCount = useMemo(() => adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, [adminFilteredSounds, adminSelection, soundKey]); const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; const analyticsTop = analytics.mostPlayed.slice(0, 10); const totalSoundsDisplay = analytics.totalSounds || total; const clockMain = clock.slice(0, 5); const clockSec = clock.slice(5); /* ════════════════════════════════════════════ RENDER ════════════════════════════════════════════ */ return (
{chaosMode &&
} {/* ═══ TOPBAR ═══ */}
music_note
Jukebox420 {/* 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}
{lastPlayed && (
Last Played: {lastPlayed}
)} {selected && (
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> Verbunden {voiceStats?.voicePing != null && ( {voiceStats.voicePing}ms )}
)}
{/* ═══ TOOLBAR ═══ */}
search setQuery(e.target.value)} /> {query && ( )}
link setImportUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} />
{ 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) { if (volDebounceRef.current) clearTimeout(volDebounceRef.current); volDebounceRef.current = setTimeout(() => { setVolumeLive(guildId, v).catch(() => {}); }, 120); } }} 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)} /> ))}
library_music
Sounds gesamt {totalSoundsDisplay}
leaderboard
Most Played
{analyticsTop.length === 0 ? ( Noch keine Plays ) : ( analyticsTop.map((item, idx) => ( {idx + 1}. {item.name} ({item.count}) )) )}
{/* ═══ 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}}
); })}
)}
{/* ═══ 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; await deleteAdminPaths([path]); setCtxMenu(null); }}> delete Löschen
)}
)} {/* ═══ CONNECTION MODAL ═══ */} {showConnModal && voiceStats && (() => { const uptimeSec = voiceStats.connectedSince ? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000) : 0; const h = Math.floor(uptimeSec / 3600); const m = Math.floor((uptimeSec % 3600) / 60); const s = uptimeSec % 60; const uptimeStr = h > 0 ? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s` : m > 0 ? `${m}m ${String(s).padStart(2,'0')}s` : `${s}s`; const pingColor = (ms: number | null) => ms == null ? 'var(--muted)' : ms < 80 ? 'var(--green)' : ms < 150 ? '#f0a830' : '#e04040'; return (
setShowConnModal(false)}>
e.stopPropagation()}>
cell_tower Verbindungsdetails
Voice Ping {voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'}
Gateway Ping {voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
Status {voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status}
Kanal {voiceStats.channelName || '---'}
Verbunden seit {uptimeStr}
); })()} {/* ═══ 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

setAdminQuery(e.target.value)} placeholder="Nach Name, Ordner oder Pfad filtern..." />
{adminLoading ? (
Lade Sounds...
) : adminFilteredSounds.length === 0 ? (
Keine Sounds gefunden.
) : (
{adminFilteredSounds.map(sound => { const key = soundKey(sound); const editing = renameTarget === key; return (
{sound.name}
{sound.folder ? `Ordner: ${sound.folder}` : 'Root'} {' · '} {key}
{editing && (
setRenameValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void submitRename(); if (e.key === 'Escape') cancelRename(); }} placeholder="Neuer Name..." />
)}
{!editing && (
)}
); })}
)}
)}
)} {/* ── Drag & Drop Overlay ── */} {isDragging && (
cloud_upload
MP3 & WAV hier ablegen
Mehrere Dateien gleichzeitig möglich
)} {/* ── Upload-Queue ── */} {showUploads && uploads.length > 0 && (
upload {uploads.every(u => u.status === 'done' || u.status === 'error') ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` : `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`}
{uploads.map(u => (
audio_file
{u.savedName ?? u.file.name}
{(u.file.size / 1024).toFixed(0)} KB
{(u.status === 'waiting' || u.status === 'uploading') && (
)} {u.status === 'done' ? 'check_circle' : u.status === 'error' ? 'error' : u.status === 'uploading' ? 'sync' : 'schedule'} {u.status === 'error' &&
{u.error}
}
))}
)}
); }