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, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents } from './api'; import type { VoiceChannelInfo, Sound, Category } 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 [categories, setCategories] = useState([]); const [activeCategoryId, setActiveCategoryId] = useState(''); 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 [assignCategoryId, setAssignCategoryId] = useState(''); const [newCategoryName, setNewCategoryName] = useState(''); const [editingCategoryId, setEditingCategoryId] = useState(''); const [editingCategoryName, setEditingCategoryName] = useState(''); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const emojiPickerRef = useRef(null); const emojiTriggerRef = useRef(null); const [emojiPos, setEmojiPos] = useState<{left:number; top:number}>({ left: 0, top: 0 }); const EMOJIS = useMemo(()=>{ // einfache, breite Auswahl gängiger Emojis; kann später erweitert/extern geladen werden const groups = [ '😀😁😂🤣😅😊🙂😉😍😘😜🤪🤗🤔🤩🥳😎😴🤤','😇🥰🥺😡🤬😱😭🙈🙉🙊💀👻🤖🎃','👍👎👏🙌🙏🤝💪🔥✨💥🎉🎊','❤️🧡💛💚💙💜🖤🤍🤎💖💘💝','⭐🌟🌈☀️🌙⚡❄️☔🌊🍀','🎵🎶🎧🎤🎸🥁🎹🎺🎻','🍕🍔🍟🌭🌮🍣🍺🍻🍷🥂','🐶🐱🐼🐸🦄🐧🐢🦖🐙','🚀🛸✈️🚁🚗🏎️🚓🚒','🏆🥇🥈🥉🎯🎮🎲🧩'] return groups.join('').split(''); }, []); function emojiToTwemojiUrl(emoji: string): string { const codePoints = Array.from(emoji).map(ch => ch.codePointAt(0)!.toString(16)).join('-'); // twemoji svg assets return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoints}.svg`; } const [showBroccoli, setShowBroccoli] = useState(false); const [partyActiveGuilds, setPartyActiveGuilds] = 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(''); const [chaosMode, setChaosMode] = useState(false); const chaosTimeoutRef = useRef(null); const chaosModeRef = useRef(false); useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); 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 cats = await fetchCategories(); setCategories(cats.categories || []); } catch {} try { const h = await fetch('/api/health').then(r => r.json()).catch(() => null); if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); } catch {} })(); }, []); // SSE: Partymode-Status global synchronisieren (sauberes Cleanup) 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 : []); } }); return () => { try { unsub(); } catch {} }; }, []); // Aus aktivem Guild-Status die lokale Anzeige setzen useEffect(() => { const gid = selected ? selected.split(':')[0] : ''; setChaosMode(gid ? partyActiveGuilds.includes(gid) : false); }, [selected, partyActiveGuilds]); // 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, activeCategoryId || undefined); setSounds(s.items); setTotal(s.total); setFolders(s.folders); } catch (e: any) { setError(e?.message || 'Fehler beim Laden der Sounds'); } })(); }, [activeFolder, query, activeCategoryId]); // 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); if (import.meta.env.VITE_BUILD_CHANNEL === 'nightly') { document.body.setAttribute('data-build', 'nightly'); } else { document.body.removeAttribute('data-build'); } 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); }, []); // Live-Update für totalPlays Counter useEffect(() => { const updateTotalPlays = async () => { try { const h = await fetch('/api/health').then(r => r.json()).catch(() => null); if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); } catch {} }; // Sofort beim Start laden updateTotalPlays(); // Alle 5 Sekunden aktualisieren const interval = setInterval(updateTotalPlays, 5000); return () => clearInterval(interval); }, []); 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); } } // CHAOS Mode Funktionen (zufällige Wiedergabe alle 1-3 Minuten) const startChaosMode = async () => { if (!selected || !sounds.length) return; const playRandomSound = async () => { const pool = sounds; if (!pool.length || !selected) return; const randomSound = pool[Math.floor(Math.random() * pool.length)]; const [guildId, channelId] = selected.split(':'); try { await playSound(randomSound.name, guildId, channelId, volume, randomSound.relativePath); } catch (e: any) { console.error('Chaos sound play failed:', e); } }; const scheduleNextPlay = async () => { if (!chaosModeRef.current) return; await playRandomSound(); const delay = 30_000 + Math.floor(Math.random() * 60_000); // 30-90 Sekunden chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay); }; // Sofort ersten Sound abspielen await playRandomSound(); // Nächsten zufällig in 1-3 Minuten planen const firstDelay = 30_000 + Math.floor(Math.random() * 60_000); chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay); }; const stopChaosMode = async () => { if (chaosTimeoutRef.current) { clearTimeout(chaosTimeoutRef.current); chaosTimeoutRef.current = null; } // Alle Sounds stoppen (wie Panic Button) if (selected) { const [guildId] = selected.split(':'); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch (e: any) { console.error('Chaos stop failed:', e); } } }; const toggleChaosMode = async () => { if (chaosMode) { setChaosMode(false); await stopChaosMode(); // serverseitig stoppen if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch {} } } else { setChaosMode(true); await startChaosMode(); // serverseitig starten if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch {} } } }; // Cleanup bei Komponenten-Unmount useEffect(() => { return () => { if (chaosTimeoutRef.current) { clearTimeout(chaosTimeoutRef.current); } }; }, []); return (
{/* Floating Broccoli for 420 Theme */} {theme === '420' && showBroccoli && ( <>
🥦
🥦
🥦
🥦
🥦
🥦
)}

Jukebox 420 {import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
v{import.meta.env.VITE_APP_VERSION || '1.1.0'} • Nightly
)}

{clock}

Sounds

{total}

Played

{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
{theme === '420' && (
setShowBroccoli(e.target.checked)} className="w-4 h-4 accent-green-500" />
)}
{!isAdmin ? ( <>
setAdminPwd(e.target.value)} onKeyDown={async (e)=>{ if(e.key === 'Enter') { const ok = await adminLogin(adminPwd); if(ok) { setIsAdmin(true); setAdminPwd(''); } else { alert('Login fehlgeschlagen'); } } }} /> 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'); } }} /> )} {/* Kategorien-Zuweisung */} {selectedCount > 0 && ( <> {/* Custom Emoji Feature entfernt */} )}
{/* Kategorien: anlegen/umbenennen/löschen */} setNewCategoryName(e.target.value)} style={{maxWidth:200}} /> setEditingCategoryName(e.target.value)} style={{maxWidth:200}} />
)}
{error &&
{error}
} {info &&
{info}
}
{folders.map(f=> { const displayName = f.name.replace(/\s*\(\d+\)\s*$/, ''); return ( ); })}
{categories.length > 0 && (
{categories.map(cat => ( ))}
)}
{(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} {Array.isArray((s as any).badges) && (s as any).badges!.map((b:string, i:number)=> ( {b==='new'?'🆕': b==='rocket'?'🚀': b} ))}
); })}
{/* Footer: Version/Channel */}
v{import.meta.env.VITE_APP_VERSION || ''} {import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && ( • Nightly )}
{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(); }} style={{ color: '#000000' }} />
); }