import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import './soundboard.css'; /* ══════════════════════════════════════════════════════════════════ INLINED TYPES (from original types.ts) ══════════════════════════════════════════════════════════════════ */ type Sound = { fileName: string; name: string; folder?: string; relativePath?: string; isRecent?: boolean; badges?: string[]; }; type SoundsResponse = { items: Sound[]; total: number; folders: Array<{ key: string; name: string; count: number }>; categories?: Category[]; fileCategories?: Record; }; type VoiceChannelInfo = { guildId: string; guildName: string; channelId: string; channelName: string; members?: number; selected?: boolean; }; type Category = { id: string; name: string; color?: string; sort?: number }; type AnalyticsItem = { name: string; relativePath: string; count: number; }; type AnalyticsResponse = { totalSounds: number; totalPlays: number; mostPlayed: AnalyticsItem[]; }; /* ══════════════════════════════════════════════════════════════════ INLINED COOKIE HELPERS (from original cookies.ts) ══════════════════════════════════════════════════════════════════ */ function setCookie(name: string, value: string, days = 365): void { const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; } function getCookie(name: string): string | null { const key = `${encodeURIComponent(name)}=`; const parts = document.cookie.split(';'); for (const part of parts) { const trimmed = part.trim(); if (trimmed.startsWith(key)) { return decodeURIComponent(trimmed.slice(key.length)); } } return null; } /* ══════════════════════════════════════════════════════════════════ INLINED API FUNCTIONS (from original api.ts) All endpoints prefixed with /api/soundboard/ instead of /api/ ══════════════════════════════════════════════════════════════════ */ const API_BASE = '/api/soundboard'; async function fetchSounds(q?: string, folderKey?: string, categoryId?: string, fuzzy?: boolean): Promise { const url = new URL(`${API_BASE}/sounds`, window.location.origin); if (q) url.searchParams.set('q', q); if (folderKey !== undefined) url.searchParams.set('folder', folderKey); if (categoryId) url.searchParams.set('categoryId', categoryId); if (typeof fuzzy === 'boolean') url.searchParams.set('fuzzy', fuzzy ? '1' : '0'); const res = await fetch(url.toString()); if (!res.ok) throw new Error('Fehler beim Laden der Sounds'); return res.json(); } async function fetchAnalytics(): Promise { const res = await fetch(`${API_BASE}/analytics`); if (!res.ok) throw new Error('Fehler beim Laden der Analytics'); return res.json(); } async function fetchCategories() { const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' }); if (!res.ok) throw new Error('Fehler beim Laden der Kategorien'); return res.json(); } async function fetchChannels(): Promise { const res = await fetch(`${API_BASE}/channels`); if (!res.ok) throw new Error('Fehler beim Laden der Channels'); return res.json(); } async function getSelectedChannels(): Promise> { const res = await fetch(`${API_BASE}/selected-channels`); if (!res.ok) throw new Error('Fehler beim Laden der Channel-Auswahl'); const data = await res.json(); return data?.selected || {}; } async function apiSetSelectedChannel(guildId: string, channelId: string): Promise { const res = await fetch(`${API_BASE}/selected-channel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId, channelId }) }); if (!res.ok) throw new Error('Channel-Auswahl setzen fehlgeschlagen'); } async function apiPlaySound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise { const res = await fetch(`${API_BASE}/play`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ soundName, guildId, channelId, volume, relativePath }) }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error || 'Play fehlgeschlagen'); } } async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise<{ saved?: string }> { const res = await fetch(`${API_BASE}/play-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, guildId, channelId, volume }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen'); return data; } async function apiDownloadUrl(url: string): Promise<{ saved?: string }> { const res = await fetch(`${API_BASE}/download-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen'); return data; } async function apiPartyStart(guildId: string, channelId: string) { const res = await fetch(`${API_BASE}/party/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId, channelId }) }); if (!res.ok) throw new Error('Partymode Start fehlgeschlagen'); } async function apiPartyStop(guildId: string) { const res = await fetch(`${API_BASE}/party/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId }) }); if (!res.ok) throw new Error('Partymode Stop fehlgeschlagen'); } async function apiSetVolumeLive(guildId: string, volume: number): Promise { const res = await fetch(`${API_BASE}/volume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId, volume }) }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error || 'Volume aendern fehlgeschlagen'); } } async function apiGetVolume(guildId: string): Promise { const url = new URL(`${API_BASE}/volume`, window.location.origin); url.searchParams.set('guildId', guildId); const res = await fetch(url.toString()); if (!res.ok) throw new Error('Fehler beim Laden der Lautstaerke'); const data = await res.json(); return typeof data?.volume === 'number' ? data.volume : 1; } async function apiAdminStatus(): Promise { const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' }); if (!res.ok) return false; const data = await res.json(); return !!data?.authenticated; } async function apiAdminLogin(password: string): Promise { const res = await fetch(`${API_BASE}/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ password }) }); return res.ok; } async function apiAdminLogout(): Promise { await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' }); } async function apiAdminDelete(paths: string[]): Promise { const res = await fetch(`${API_BASE}/admin/sounds/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ paths }) }); if (!res.ok) throw new Error('Loeschen fehlgeschlagen'); } async function apiAdminRename(from: string, to: string): Promise { const res = await fetch(`${API_BASE}/admin/sounds/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ from, to }) }); if (!res.ok) throw new Error('Umbenennen fehlgeschlagen'); const data = await res.json(); return data?.to as string; } function apiUploadFile( file: File, onProgress: (pct: number) => void, ): Promise { return new Promise((resolve, reject) => { const form = new FormData(); form.append('files', file); const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE}/upload`); xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status === 200) { try { const data = JSON.parse(xhr.responseText); resolve(data.files?.[0]?.name ?? file.name); } catch { resolve(file.name); } } else { try { reject(new Error(JSON.parse(xhr.responseText).error)); } catch { reject(new Error(`HTTP ${xhr.status}`)); } } }; xhr.onerror = () => reject(new Error('Netzwerkfehler')); xhr.send(form); }); } /* ══════════════════════════════════════════════════════════════════ CONSTANTS ══════════════════════════════════════════════════════════════════ */ 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; }; interface VoiceStats { voicePing: number | null; gatewayPing: number; status: string; channelName: string | null; connectedSince: string | null; } /* ══════════════════════════════════════════════════════════════════ PROPS — receives SSE data from the Hub ══════════════════════════════════════════════════════════════════ */ interface SoundboardTabProps { data: any; } /* ══════════════════════════════════════════════════════════════════ COMPONENT ══════════════════════════════════════════════════════════════════ */ export default function SoundboardTab({ data }: SoundboardTabProps) { /* ── 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>(undefined); /* ── 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>(undefined); /* ── Voice Stats ── */ 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: global window listeners ── */ 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 YTDLP_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com', 'instagram.com', 'www.instagram.com']; /** Auto-prepend https:// if missing */ const normalizeUrl = useCallback((value: string): string => { const v = value.trim(); if (!v) return v; if (/^https?:\/\//i.test(v)) return v; return 'https://' + v; }, []); const isSupportedUrl = useCallback((value: string) => { try { const parsed = new URL(normalizeUrl(value)); const host = parsed.hostname.toLowerCase(); if (parsed.pathname.toLowerCase().endsWith('.mp3')) return true; if (YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h))) return true; return false; } catch { return false; } }, [normalizeUrl]); const getUrlType = useCallback((value: string): 'youtube' | 'instagram' | 'mp3' | null => { try { const parsed = new URL(normalizeUrl(value)); const host = parsed.hostname.toLowerCase(); if (host.includes('youtube') || host === 'youtu.be') return 'youtube'; if (host.includes('instagram')) return 'instagram'; if (parsed.pathname.toLowerCase().endsWith('.mp3')) return 'mp3'; return null; } catch { return null; } }, [normalizeUrl]); 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 apiAdminStatus()); } catch { } try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /* ── Theme (persist only, data-theme is set on .sb-app div) ── */ useEffect(() => { localStorage.setItem('jb-theme', theme); }, [theme]); /* ── Card size (scoped to .sb-app container) ── */ const sbAppRef = useRef(null); useEffect(() => { const el = sbAppRef.current; if (!el) return; el.style.setProperty('--card-size', cardSize + 'px'); const ratio = cardSize / 110; el.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); el.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); localStorage.setItem('jb-card-size', String(cardSize)); }, [cardSize]); /* ── SSE via props.data instead of own EventSource ── */ useEffect(() => { if (!data) return; // Handle snapshot data (initial load from hub SSE) if (data.soundboard) { const sb = data.soundboard; if (Array.isArray(sb.party)) { setPartyActiveGuilds(sb.party); } try { const sel = sb.selected || {}; const g = selectedRef.current?.split(':')[0]; if (g && sel[g]) setSelected(`${g}:${sel[g]}`); } catch { } try { const vols = sb.volumes || {}; const g = selectedRef.current?.split(':')[0]; if (g && typeof vols[g] === 'number') setVolume(vols[g]); } catch { } try { const np = sb.nowplaying || {}; const g = selectedRef.current?.split(':')[0]; if (g && typeof np[g] === 'string') setLastPlayed(np[g]); } catch { } try { const vs = sb.voicestats || {}; const g = selectedRef.current?.split(':')[0]; if (g && vs[g]) setVoiceStats(vs[g]); } catch { } } // Handle individual SSE event types if (data.type === 'soundboard_party') { setPartyActiveGuilds(prev => { const s = new Set(prev); if (data.active) s.add(data.guildId); else s.delete(data.guildId); return Array.from(s); }); } else if (data.type === 'soundboard_channel') { const g = selectedRef.current?.split(':')[0]; if (data.guildId === g) setSelected(`${data.guildId}:${data.channelId}`); } else if (data.type === 'soundboard_volume') { const g = selectedRef.current?.split(':')[0]; if (data.guildId === g && typeof data.volume === 'number') setVolume(data.volume); } else if (data.type === 'soundboard_nowplaying') { const g = selectedRef.current?.split(':')[0]; if (data.guildId === g) setLastPlayed(data.name || ''); } else if (data.type === 'soundboard_voicestats') { const g = selectedRef.current?.split(':')[0]; if (data.guildId === g) { setVoiceStats({ voicePing: data.voicePing, gatewayPing: data.gatewayPing, status: data.status, channelName: data.channelName, connectedSince: data.connectedSince, }); } } }, [data]); useEffect(() => { setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); }, [selected, partyActiveGuilds, guildId]); /* ── 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, notify]); 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 apiGetVolume(guildId); setVolume(v); } catch { } })(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [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(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [showAdmin, isAdmin]); /* ── Actions ── */ async function loadAnalytics() { try { const d = await fetchAnalytics(); setAnalytics(d); } catch { } } async function handlePlay(s: Sound) { if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error'); try { await apiPlaySound(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 = normalizeUrl(importUrl); if (!trimmed) return notify('Bitte einen Link eingeben', 'error'); if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error'); setImportBusy(true); const urlType = getUrlType(trimmed); try { let savedName: string | undefined; if (selected && guildId && channelId) { // Voice channel selected → download + play const result = await apiPlayUrl(trimmed, guildId, channelId, volume); savedName = result.saved; } else { // No voice channel → download only const result = await apiDownloadUrl(trimmed); savedName = result.saved; } setImportUrl(''); const typeLabel = urlType === 'youtube' ? 'YouTube' : urlType === 'instagram' ? 'Instagram' : 'MP3'; notify(`${typeLabel} gespeichert${selected ? ' und abgespielt' : ''}: ${savedName ?? ''}`); setRefreshKey(k => k + 1); await loadAnalytics(); } catch (e: any) { notify(e?.message || 'Download 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 apiUploadFile( 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]); } // Refresh sound list setRefreshKey(k => k + 1); void loadAnalytics(); // Auto-dismiss after 3.5s uploadDismissRef.current = setTimeout(() => { setShowUploads(false); setUploads([]); }, 3500); } async function handleStop() { if (!selected) return; setLastPlayed(''); try { await fetch(`${API_BASE}/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 apiPartyStop(guildId); } catch { } } else { if (!selected) return notify('Bitte einen Channel auswaehlen', 'error'); try { await apiPartyStart(guildId, channelId); } catch { } } } async function handleChannelSelect(ch: VoiceChannelInfo) { const v = `${ch.guildId}:${ch.channelId}`; setSelected(v); setChannelOpen(false); try { await apiSetSelectedChannel(ch.guildId, ch.channelId); } catch { } } function toggleFav(key: string) { setFavs(prev => ({ ...prev, [key]: !prev[key] })); } async function loadAdminSounds() { setAdminLoading(true); try { const d = await fetchSounds('', '__all__', undefined, false); setAdminSounds(d.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 gueltigen Namen eingeben', 'error'); return; } try { await apiAdminRename(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 apiAdminDelete(paths); notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`); setAdminSelection({}); cancelRename(); setRefreshKey(k => k + 1); if (showAdmin) await loadAdminSounds(); } catch (e: any) { notify(e?.message || 'Loeschen fehlgeschlagen', 'error'); } } async function handleAdminLogin() { try { const ok = await apiAdminLogin(adminPwd); if (ok) { setIsAdmin(true); setAdminPwd(''); notify('Admin eingeloggt'); } else notify('Falsches Passwort', 'error'); } catch { notify('Login fehlgeschlagen', 'error'); } } async function handleAdminLogout() { try { await apiAdminLogout(); 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
Soundboard {/* Channel Dropdown */}
e.stopPropagation()}> {channelOpen && (
{Object.entries(channelsByGuild).map(([guild, chs]) => (
{guild}
{chs.map(ch => (
handleChannelSelect(ch)} > volume_up {ch.channelName}{ch.members ? ` (${ch.members})` : ''}
))}
))} {channels.length === 0 && (
Keine Channels verfuegbar
)}
)}
{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 && ( )}
{getUrlType(importUrl) === 'youtube' ? 'smart_display' : getUrlType(importUrl) === 'instagram' ? 'photo_camera' : 'link'} setImportUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} /> {importUrl && ( {getUrlType(importUrl) === 'youtube' ? 'YT' : getUrlType(importUrl) === 'instagram' ? 'IG' : getUrlType(importUrl) === 'mp3' ? 'MP3' : '?'} )}
{ const newVol = volume > 0 ? 0 : 0.5; setVolume(newVol); if (guildId) apiSetVolumeLive(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(() => { apiSetVolumeLive(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' ? '\u2B50' : '\uD83D\uDD07'}
{activeTab === 'favorites' ? 'Noch keine Favoriten' : query ? `Kein Sound fuer "${query}" gefunden` : 'Keine Sounds vorhanden'}
{activeTab === 'favorites' ? 'Klick den Stern auf einem Sound!' : 'Hier gibt\'s noch nichts zu hoeren.'}
) : (
{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 Loeschen
)}
)} {/* ═══ CONNECTION MODAL ═══ */} {showConnModal && (() => { 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 && voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
Status {voiceStats?.status === 'ready' ? 'Verbunden' : voiceStats?.status ?? 'Warte auf Verbindung'}
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'} {' \u00B7 '} {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 moeglich
)} {/* ── 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\u2026 (${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}
}
))}
)}
); }