jukebox-vibe/web/src/App.tsx

807 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, getSelectedChannels, setSelectedChannel } from './api';
import type { VoiceChannelInfo, Sound, Category } from './types';
import { getCookie, setCookie } from './cookies';
export default function App() {
const [sounds, setSounds] = useState<Sound[]>([]);
const [total, setTotal] = useState<number>(0);
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
const [activeFolder, setActiveFolder] = useState<string>('__all__');
const [categories, setCategories] = useState<Category[]>([]);
const [activeCategoryId, setActiveCategoryId] = useState<string>('');
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
const [query, setQuery] = useState('');
const [fuzzy, setFuzzy] = useState<boolean>(false);
const [selected, setSelected] = useState<string>('');
const selectedRef = useRef<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [showTop, setShowTop] = useState<boolean>(false);
const [volume, setVolume] = useState<number>(1);
const [favs, setFavs] = useState<Record<string, boolean>>({});
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [adminPwd, setAdminPwd] = useState<string>('');
const [selectedSet, setSelectedSet] = useState<Record<string, boolean>>({});
const [assignCategoryId, setAssignCategoryId] = useState<string>('');
const [newCategoryName, setNewCategoryName] = useState<string>('');
const [editingCategoryId, setEditingCategoryId] = useState<string>('');
const [editingCategoryName, setEditingCategoryName] = useState<string>('');
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const emojiPickerRef = useRef<HTMLDivElement|null>(null);
const emojiTriggerRef = useRef<HTMLButtonElement|null>(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<boolean>(false);
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]);
const [clock, setClock] = useState<string>(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date()));
const [totalPlays, setTotalPlays] = useState<number>(0);
const [mediaUrl, setMediaUrl] = useState<string>('');
const [chaosMode, setChaosMode] = useState<boolean>(false);
const chaosTimeoutRef = useRef<number | null>(null);
const chaosModeRef = useRef<boolean>(false);
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
useEffect(() => { selectedRef.current = selected; }, [selected]);
useEffect(() => {
(async () => {
try {
const [c, selectedMap] = await Promise.all([fetchChannels(), getSelectedChannels()]);
setChannels(c);
let initial = '';
if (c.length > 0) {
const firstGuild = c[0].guildId;
const serverCid = selectedMap[firstGuild];
if (serverCid && c.find(x => x.guildId === firstGuild && x.channelId === serverCid)) {
initial = `${firstGuild}:${serverCid}`;
} else {
initial = `${c[0].guildId}:${c[0].channelId}`;
}
}
if (initial) setSelected(initial);
} 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 : []);
try {
const sel = msg?.selected || {};
const currentSelected = selectedRef.current || '';
const gid = currentSelected ? currentSelected.split(':')[0] : '';
if (gid && sel[gid]) {
const newVal = `${gid}:${sel[gid]}`;
setSelected(newVal);
}
} catch {}
try {
const vols = msg?.volumes || {};
const cur = selectedRef.current || '';
const gid = cur ? cur.split(':')[0] : '';
if (gid && typeof vols[gid] === 'number') {
const v = vols[gid];
setVolume(v);
}
} catch {}
} else if (msg?.type === 'channel') {
try {
const gid = msg.guildId;
const cid = msg.channelId;
if (gid && cid) {
const currentSelected = selectedRef.current || '';
const curGid = currentSelected ? currentSelected.split(':')[0] : '';
if (curGid === gid) setSelected(`${gid}:${cid}`);
}
} catch {}
} else if (msg?.type === 'volume') {
try {
const gid = msg.guildId;
const v = msg.volume;
const cur = selectedRef.current || '';
const curGid = cur ? cur.split(':')[0] : '';
if (gid && curGid === gid && typeof v === 'number') {
setVolume(v);
}
} catch {}
}
});
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, fuzzy);
setSounds(s.items);
setTotal(s.total);
setFolders(s.folders);
} catch (e: any) {
setError(e?.message || 'Fehler beim Laden der Sounds');
}
})();
}, [activeFolder, query, activeCategoryId, fuzzy]);
// 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]);
// Server liefert bereits gefilterte (und ggf. fuzzy-sortierte) Ergebnisse
const filtered = sounds;
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 (
<ErrorBoundary>
<div className="page-container" data-theme={theme}>
{/* Floating Broccoli for 420 Theme */}
{theme === '420' && showBroccoli && (
<>
<div className="broccoli">🥦</div>
<div className="broccoli">🥦</div>
<div className="broccoli">🥦</div>
<div className="broccoli">🥦</div>
<div className="broccoli">🥦</div>
<div className="broccoli">🥦</div>
</>
)}
<header className="flex items-center justify-between p-6">
<div className="flex items-center">
<div>
<h1 className="text-4xl font-bold site-title">
Jukebox 420
{import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
<div className="text-sm font-normal mt-1 opacity-70">
<span className="ml-2" style={{ color: '#ff4d4f' }}> Nightly</span>
</div>
)}
</h1>
<p className="text-7xl font-bold mt-2">{clock}</p>
</div>
</div>
<div className="flex items-center space-x-8">
<div className="text-center">
<p className="text-lg text-gray-400">Sounds</p>
<p className="text-2xl font-bold">{total}</p>
</div>
<div className="text-center">
<p className="text-lg text-gray-400">Played</p>
<p className="text-2xl font-bold">{totalPlays}</p>
</div>
<div className="flex items-center space-x-4">
<button className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async () => {
try { const res = await fetch('/api/sounds'); const data = await res.json(); const items = data?.items || []; if (!items.length || !selected) return; const rnd = items[Math.floor(Math.random()*items.length)]; const [guildId, channelId] = selected.split(':'); await playSound(rnd.name, guildId, channelId, volume, rnd.relativePath);} catch {}
}}>Random</button>
<button
className={`font-bold py-3 px-6 rounded-lg transition duration-300 ${
chaosMode
? 'chaos-rainbow text-white'
: 'bg-gray-700 hover:bg-gray-600 text-white'
}`}
onClick={toggleChaosMode}
>
Partymode
</button>
<button className="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async () => { setChaosMode(false); await stopChaosMode(); if(selected){ const [guildId]=selected.split(':'); try{ await partyStop(guildId);}catch{} } }}>Panic</button>
</div>
</div>
</header>
<div className="control-panel rounded-xl p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-center">
<div className="flex items-center gap-3">
<button
className={`font-bold p-3 rounded-lg transition duration-300 ${fuzzy ? 'bg-green-600 hover:bg-green-500 text-white' : 'bg-gray-700 hover:bg-gray-600 text-white'}`}
onClick={() => setFuzzy((v) => !v)}
title="Fuzzy-Suche umschalten"
aria-label="Fuzzy-Suche umschalten"
aria-pressed={fuzzy}
>
<span className="material-icons" aria-hidden="true">blur_on</span>
</button>
<div className="relative flex-1">
<input className="input-field pl-10 with-left-icon w-full" placeholder="Nach Sounds suchen..." value={query} onChange={(e)=>setQuery(e.target.value)} />
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>search</span>
</div>
</div>
<div className="relative">
<CustomSelect channels={channels} value={selected} onChange={async (v)=>{
setSelected(v);
try {
const [gid, cid] = v.split(':');
await setSelectedChannel(gid, cid);
} catch (e) { /* noop */ }
}} />
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>folder_special</span>
</div>
<div className="flex items-center space-x-3">
<span className="material-icons" style={{color:'var(--text-secondary)'}}>volume_up</span>
<input
className="volume-slider w-full h-2 rounded-lg appearance-none cursor-pointer"
type="range" min={0} max={1} step={0.01}
value={volume}
onChange={async (e)=>{
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)}%` }}
/>
<span className="text-sm font-semibold w-8 text-center" style={{color:'var(--text-secondary)'}}>{Math.round(volume*100)}%</span>
</div>
<div className="relative md:col-span-2 lg:col-span-1">
<input className="input-field pl-10 with-left-icon" placeholder="MP3 URL..." value={mediaUrl} onChange={(e)=>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'); } } }} />
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>link</span>
<button className="absolute right-0 top-0 h-full px-4 text-white flex items-center rounded-r-lg transition-all font-semibold" style={{background:'var(--accent-green)'}} onClick={async ()=>{ 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(e:any){ setInfo(null); setError(e?.message||'Download fehlgeschlagen'); } }}>
<span className="material-icons text-sm mr-1">file_download</span>
Download
</button>
</div>
<div className="flex items-center space-x-3 lg:col-span-2">
<div className="relative flex-grow">
<select title="Theme Auswahl" className="input-field appearance-none pl-10" value={theme} onChange={(e)=>setTheme(e.target.value)}>
<option value="dark">Theme: Dark</option>
<option value="rainbow">Theme: Rainbow</option>
<option value="420">Theme: 420</option>
</select>
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>palette</span>
<span className="material-icons absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" style={{color:'var(--text-secondary)'}}>unfold_more</span>
</div>
{theme === '420' && (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="broccoli-toggle"
checked={showBroccoli}
onChange={(e) => setShowBroccoli(e.target.checked)}
className="w-4 h-4 accent-green-500"
/>
<label htmlFor="broccoli-toggle" className="text-sm font-medium" style={{color:'var(--text-secondary)'}}>
Brokkoli?
</label>
</div>
)}
</div>
</div>
<div className="mt-6" style={{borderTop:'1px solid var(--border-color)', paddingTop:'1.5rem'}}>
<div className="flex items-center gap-4 justify-between flex-wrap">
{!isAdmin ? (
<>
<div className="relative w-full sm:w-auto" style={{maxWidth:'15%'}}>
<input
className="input-field pl-10 with-left-icon"
placeholder="Admin Passwort"
type="password"
value={adminPwd}
onChange={(e)=>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');
}
}
}}
/>
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>lock</span>
</div>
<button className="bg-gray-800 text-white hover:bg-black font-semibold py-2 px-5 rounded-lg transition-all w-full sm:w-auto" style={{maxWidth:'15%'}} onClick={async ()=>{ const ok=await adminLogin(adminPwd); if(ok){ setIsAdmin(true); setAdminPwd(''); } else alert('Login fehlgeschlagen'); }}>Login</button>
</>
) : (
<div className="flex items-center gap-3 w-full">
<span className="bg-gray-700 text-white font-bold py-3 px-6 rounded-lg">Ausgewählt: {selectedCount}</span>
{selectedCount > 0 && (
<button
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
onClick={async ()=>{
try {
const toDelete = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
await adminDelete(toDelete);
clearSelection();
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined, fuzzy);
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
} catch (e:any) { setError(e?.message||'Löschen fehlgeschlagen'); }
}}
>
Löschen
</button>
)}
{selectedCount === 1 && (
<RenameInline onSubmit={async (newName)=>{
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, activeCategoryId || undefined, fuzzy);
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
} catch (e:any) { setError(e?.message||'Umbenennen fehlgeschlagen'); }
}} />
)}
{/* Kategorien-Zuweisung */}
{selectedCount > 0 && (
<>
<select className="input-field" value={assignCategoryId} onChange={(e)=>setAssignCategoryId(e.target.value)} style={{maxWidth:200}}>
<option value="">Kategorie wählen</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
onClick={async ()=>{
try{
const files = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
if(!assignCategoryId){ setError('Bitte Kategorie wählen'); return; }
await assignCategories(files, [assignCategoryId], []);
setInfo('Kategorie zugewiesen'); setError(null);
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined, fuzzy);
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
}catch(e:any){ setError(e?.message||'Zuweisung fehlgeschlagen'); setInfo(null); }
}}
>Zu Kategorie</button>
{/* Custom Emoji Feature entfernt */}
<button
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
onClick={async ()=>{
try{
const files = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
await clearBadges(files);
setInfo('Alle Custom-Badges entfernt'); setError(null);
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined, fuzzy);
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
}catch(err:any){ setError(err?.message||'Badge-Entfernung fehlgeschlagen'); setInfo(null); }
}}
>Badges entfernen</button>
</>
)}
<div className="flex-1" />
{/* Kategorien: anlegen/umbenennen/löschen */}
<input className="input-field" placeholder="Neue Kategorie" value={newCategoryName} onChange={(e)=>setNewCategoryName(e.target.value)} style={{maxWidth:200}} />
<button className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async()=>{
try{
const n = newCategoryName.trim(); if(!n){ setError('Name fehlt'); return; }
await createCategory(n);
setNewCategoryName('');
const cats = await fetchCategories(); setCategories(cats.categories || []);
setInfo('Kategorie erstellt'); setError(null);
}catch(e:any){ setError(e?.message||'Anlegen fehlgeschlagen'); setInfo(null); }
}}>Anlegen</button>
<select className="input-field" value={editingCategoryId} onChange={(e)=>{ setEditingCategoryId(e.target.value); const c = categories.find(x=>x.id===e.target.value); setEditingCategoryName(c?.name||''); }} style={{maxWidth:200}}>
<option value="">Kategorie wählen</option>
{categories.map(c=> <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<input className="input-field" placeholder="Neuer Name" value={editingCategoryName} onChange={(e)=>setEditingCategoryName(e.target.value)} style={{maxWidth:200}} />
<button className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async()=>{
try{
if(!editingCategoryId){ setError('Bitte Kategorie wählen'); return; }
await updateCategory(editingCategoryId, { name: editingCategoryName.trim() });
const cats = await fetchCategories(); setCategories(cats.categories || []);
setInfo('Kategorie umbenannt'); setError(null);
}catch(e:any){ setError(e?.message||'Umbenennen fehlgeschlagen'); setInfo(null); }
}}>Umbenennen</button>
<button className="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async()=>{
try{
if(!editingCategoryId){ setError('Bitte Kategorie wählen'); return; }
await deleteCategory(editingCategoryId);
setEditingCategoryId(''); setEditingCategoryName('');
const cats = await fetchCategories(); setCategories(cats.categories || []);
setInfo('Kategorie gelöscht'); setError(null);
}catch(e:any){ setError(e?.message||'Löschen fehlgeschlagen'); setInfo(null); }
}}>Löschen</button>
<button className="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async ()=>{ try{ await adminLogout(); setIsAdmin(false); clearSelection(); } catch{} }}>Logout</button>
</div>
)}
</div>
</div>
</div>
{error && <div className="error mb-4">{error}</div>}
{info && <div className="badge mb-4" style={{ background:'rgba(34,197,94,.18)', borderColor:'rgba(34,197,94,.35)' }}>{info}</div>}
<div className="bg-transparent mb-8">
<div className="flex flex-wrap gap-3 text-sm">
<button className={`tag-btn ${activeFolder==='__favs__'?'active':''}`} onClick={()=>setActiveFolder('__favs__')}>Favoriten ({favCount})</button>
{folders.map(f=> {
const displayName = f.name.replace(/\s*\(\d+\)\s*$/, '');
return (
<button
key={f.key}
className={`tag-btn ${activeFolder===f.key?'active':''}`}
onClick={async ()=>{
setActiveFolder(f.key);
const resp=await fetchSounds(undefined, f.key, activeCategoryId || undefined);
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
}}
>
{displayName} ({f.count})
</button>
);
})}
</div>
{categories.length > 0 && (
<div className="flex flex-wrap gap-3 text-sm mt-3">
{categories.map(cat => (
<button
key={cat.id}
className={`tag-btn ${activeCategoryId===cat.id?'active':''}`}
onClick={()=> setActiveCategoryId(prev => (prev === cat.id ? '' : cat.id))}
>
{cat.name}
</button>
))}
</div>
)}
</div>
<main className="sounds-flow">
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => {
const key = `${s.relativePath ?? s.fileName}`;
const isFav = !!favs[key];
return (
<div key={`${s.fileName}-${s.name}`} className="sound-wrap">
{isAdmin && (
<input
type="checkbox"
className="select-check"
checked={!!selectedSet[key]}
onChange={(e)=>{ e.stopPropagation(); toggleSelect(key, e.target.checked); }}
/>
)}
<div className="sound-btn group rounded-xl flex items-center justify-between p-3 cursor-pointer"
onClick={()=>handlePlay(s.name, s.relativePath)}>
<span className="text-sm font-medium truncate pr-2">
{s.name}
{Array.isArray((s as any).badges) && (s as any).badges!.map((b:string, i:number)=> (
<span key={i} style={{ marginLeft: 6, opacity:.9 }}>{b==='new'?'🆕': b==='rocket'?'🚀': b}</span>
))}
</span>
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="text-gray-400 hover:text-[var(--accent-blue)]" onClick={(e)=>{e.stopPropagation(); setFavs(prev=>({ ...prev, [key]: !prev[key] }));}}><span className="material-icons text-xl">{isFav?'star':'star_border'}</span></button>
</div>
</div>
</div>
);
})}
</main>
{/* Footer intentionally left without version display */}
</div>
{showTop && (
<button type="button" className="back-to-top" aria-label="Nach oben" onClick={()=>window.scrollTo({top:0, behavior:'smooth'})}> Top</button>
)}
</ErrorBoundary>
);
}
type SelectProps = {
channels: VoiceChannelInfo[];
value: string;
onChange: (v: string) => void;
};
function CustomSelect({ channels, value, onChange }: SelectProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(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 (
<div className="control select custom-select" ref={ref}>
<button
ref={triggerRef}
type="button"
className="select-trigger"
onClick={() => setOpen(v => !v)}
title={current ? `Channel: ${current.guildName} ${current.channelName}` : 'Channel wählen'}
>
{current ? `Channel: ${current.guildName} ${current.channelName}` : 'Channel wählen'}
<span className="chev"></span>
</button>
{open && typeof document !== 'undefined' && ReactDOM.createPortal(
<div
className="select-menu"
style={{ position: 'fixed', left: menuPos.left, top: menuPos.top, width: menuPos.width, zIndex: 30000 }}
>
{channels.map((c) => {
const v = `${c.guildId}:${c.channelId}`;
const active = v === value;
return (
<button
type="button"
key={v}
className={`select-item ${active ? 'active' : ''}`}
onClick={() => { onChange(v); setOpen(false); }}
>
{c.guildName} {c.channelName}
</button>
);
})}
</div>,
document.body
)}
</div>
);
}
// 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 (
<div style={{ padding: 20 }}>
<h2>Es ist ein Fehler aufgetreten</h2>
<pre style={{ whiteSpace: 'pre-wrap' }}>{String(this.state.error.message || this.state.error)}</pre>
<button type="button" onClick={() => this.setState({ error: undefined })}>Zurück</button>
</div>
);
}
return this.props.children as any;
}
}
// Inline-Komponente für Umbenennen (nur bei genau 1 Selektion sichtbar)
type RenameInlineProps = { onSubmit: (newName: string) => void | Promise<void> };
function RenameInline({ onSubmit }: RenameInlineProps) {
const [val, setVal] = useState('');
async function submit() {
const n = val.trim();
if (!n) return;
await onSubmit(n);
setVal('');
}
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
value={val}
onChange={(e) => setVal(e.target.value)}
placeholder="Neuer Name"
onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
style={{ color: '#000000' }}
/>
<button type="button" className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={() => void submit()}>Umbenennen</button>
</div>
);
}