import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl } from './api'; import type { VoiceChannelInfo, Sound } from './types'; import { getCookie, setCookie } from './cookies'; export default function App() { const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); const [folders, setFolders] = useState>([]); const [activeFolder, setActiveFolder] = useState('__all__'); const [channels, setChannels] = useState([]); const [query, setQuery] = useState(''); const [selected, setSelected] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [showTop, setShowTop] = useState(false); const [volume, setVolume] = useState(1); const [favs, setFavs] = useState>({}); const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); const [isAdmin, setIsAdmin] = useState(false); const [adminPwd, setAdminPwd] = useState(''); const [selectedSet, setSelectedSet] = useState>({}); const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]); const [clock, setClock] = useState(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date())); const [totalPlays, setTotalPlays] = useState(0); const [mediaUrl, setMediaUrl] = useState(''); useEffect(() => { (async () => { try { const c = await fetchChannels(); setChannels(c); const stored = localStorage.getItem('selectedChannel'); if (stored && c.find(x => `${x.guildId}:${x.channelId}` === stored)) { setSelected(stored); } else if (c[0]) { setSelected(`${c[0].guildId}:${c[0].channelId}`); } } catch (e: any) { setError(e?.message || 'Fehler beim Laden der Channels'); } try { setIsAdmin(await adminStatus()); } catch {} try { const h = await fetch('/api/health').then(r => r.json()).catch(() => null); if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); } catch {} })(); }, []); // Uhrzeit (Berlin) aktualisieren useEffect(() => { const fmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }); const update = () => setClock(fmt.format(new Date())); const id = setInterval(update, 1000); update(); return () => clearInterval(id); }, []); useEffect(() => { (async () => { try { const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder; const s = await fetchSounds(query, folderParam); setSounds(s.items); setTotal(s.total); setFolders(s.folders); } catch (e: any) { setError(e?.message || 'Fehler beim Laden der Sounds'); } })(); }, [activeFolder, query]); // Favoriten aus Cookie laden useEffect(() => { const c = getCookie('favs'); if (c) { try { setFavs(JSON.parse(c)); } catch {} } }, []); // Favoriten persistieren useEffect(() => { try { setCookie('favs', JSON.stringify(favs)); } catch {} }, [favs]); // Theme anwenden/persistieren useEffect(() => { document.body.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); }, [theme]); // Back-to-top Sichtbarkeit useEffect(() => { const onScroll = () => setShowTop(window.scrollY > 300); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, []); useEffect(() => { (async () => { if (selected) { localStorage.setItem('selectedChannel', selected); // gespeicherte Lautstärke vom Server laden try { const [guildId] = selected.split(':'); const v = await getVolume(guildId); setVolume(v); } catch {} } })(); }, [selected]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return sounds; return sounds.filter((s) => s.name.toLowerCase().includes(q)); }, [sounds, query]); const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); function toggleSelect(key: string, on?: boolean) { setSelectedSet((prev) => ({ ...prev, [key]: typeof on === 'boolean' ? on : !prev[key] })); } function clearSelection() { setSelectedSet({}); } async function handlePlay(name: string, rel?: string) { setError(null); if (!selected) return setError('Bitte einen Voice-Channel auswählen'); const [guildId, channelId] = selected.split(':'); try { setLoading(true); await playSound(name, guildId, channelId, volume, rel); } catch (e: any) { setError(e?.message || 'Play fehlgeschlagen'); } finally { setLoading(false); } } return (

Jukebox 420

{clock}

Geladene Sounds

{total}

Insgesamt abgespielt

{totalPlays}

setQuery(e.target.value)} /> search
folder_special
volume_up { const v = parseFloat(e.target.value); setVolume(v); // CSS-Variable setzen, um die Füllbreite zu steuern const percent = `${Math.round(v * 100)}%`; try { (e.target as HTMLInputElement).style.setProperty('--_fill', percent); } catch {} if(selected){ const [guildId]=selected.split(':'); try{ await setVolumeLive(guildId, v);}catch{} } }} // Initiale Füllbreite, falls State geladen ist style={{ ['--_fill' as any]: `${Math.round(volume*100)}%` }} /> {Math.round(volume*100)}%
setMediaUrl(e.target.value)} onKeyDown={async (e)=>{ if(e.key==='Enter'){ if(!selected){ setError('Bitte Voice-Channel wählen'); setInfo(null); return;} const [guildId,channelId]=selected.split(':'); try{ await playUrl(mediaUrl,guildId,channelId,volume); setError(null); setInfo('MP3 heruntergeladen und abgespielt.'); }catch(err:any){ setInfo(null); setError(err?.message||'Download fehlgeschlagen'); } } }} /> link
palette unfold_more
{!isAdmin ? ( <>
setAdminPwd(e.target.value)} /> lock
) : (
Ausgewählt: {selectedCount} {selectedCount > 0 && ( )} {selectedCount === 1 && ( { const from = Object.entries(selectedSet).find(([,v])=>v)?.[0]; if(!from) return; try { await adminRename(from, newName); clearSelection(); const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder); setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders); } catch (e:any) { setError(e?.message||'Umbenennen fehlgeschlagen'); } }} /> )}
)}
{folders.map(f=> ( ))}
{error &&
{error}
} {info &&
{info}
}
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => { const key = `${s.relativePath ?? s.fileName}`; const isFav = !!favs[key]; return (
{isAdmin && ( { e.stopPropagation(); toggleSelect(key, e.target.checked); }} /> )}
handlePlay(s.name, s.relativePath)}> {s.name}
); })}
{showTop && ( )} ); } type SelectProps = { channels: VoiceChannelInfo[]; value: string; onChange: (v: string) => void; }; function CustomSelect({ channels, value, onChange }: SelectProps) { const [open, setOpen] = useState(false); const ref = useRef(null); const triggerRef = useRef(null); const [menuPos, setMenuPos] = useState<{ left: number; top: number; width: number }>({ left: 0, top: 0, width: 0 }); useEffect(() => { const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; window.addEventListener('click', close); return () => window.removeEventListener('click', close); }, []); useEffect(() => { if (!open) return; const update = () => { const el = triggerRef.current; if (!el) return; const r = el.getBoundingClientRect(); setMenuPos({ left: Math.round(r.left), top: Math.round(r.bottom + 6), width: Math.round(r.width) }); }; update(); window.addEventListener('resize', update); window.addEventListener('scroll', update, true); return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true); }; }, [open]); const current = channels.find(c => `${c.guildId}:${c.channelId}` === value); return (
{open && typeof document !== 'undefined' && ReactDOM.createPortal(
{channels.map((c) => { const v = `${c.guildId}:${c.channelId}`; const active = v === value; return ( ); })}
, document.body )}
); } // Einfache ErrorBoundary, damit die Seite nicht blank wird und Fehler sichtbar sind class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error?: Error }>{ constructor(props: { children: React.ReactNode }) { super(props); this.state = { error: undefined }; } static getDerivedStateFromError(error: Error) { return { error }; } componentDidCatch(error: Error, info: any) { console.error('UI-ErrorBoundary:', error, info); } render() { if (this.state.error) { return (

Es ist ein Fehler aufgetreten

{String(this.state.error.message || this.state.error)}
); } return this.props.children as any; } } // Inline-Komponente für Umbenennen (nur bei genau 1 Selektion sichtbar) type RenameInlineProps = { onSubmit: (newName: string) => void | Promise }; function RenameInline({ onSubmit }: RenameInlineProps) { const [val, setVal] = useState(''); async function submit() { const n = val.trim(); if (!n) return; await onSubmit(n); setVal(''); } return (
setVal(e.target.value)} placeholder="Neuer Name" onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }} />
); }