feat: complete apple ui redesign on stable

This commit is contained in:
Bot 2026-02-26 13:47:54 +01:00
parent 584bb5ca4f
commit 187905d22b
4 changed files with 849 additions and 1292 deletions

View file

@ -31,12 +31,16 @@ jobs:
echo "tag=main" >> $GITHUB_OUTPUT
echo "version=1.1.0" >> $GITHUB_OUTPUT
echo "channel=stable" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref_name }}" == "feature/nightly" ]] || [[ "${{ github.ref_name }}" == "nightly" ]]; then
echo "tag=nightly" >> $GITHUB_OUTPUT
echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT
echo "channel=nightly" >> $GITHUB_OUTPUT
else
# Ersetze Slashes durch Bindestriche für gültige Docker Tags
CLEAN_TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
echo "tag=$CLEAN_TAG" >> $GITHUB_OUTPUT
echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT
echo "channel=nightly" >> $GITHUB_OUTPUT
echo "version=1.1.0-dev" >> $GITHUB_OUTPUT
echo "channel=dev" >> $GITHUB_OUTPUT
fi
# Nur auf main: auch :latest tag pushen

View file

@ -1,21 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Soundboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<!-- No tailwind script injection to avoid collision with our custom CSS structural layout -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<script type="module" src="/src/main.tsx"></script>
</head>
<body class="p-4 sm:p-8">
<body>
<div id="root"></div>
</body>
</html>

View file

@ -1,5 +1,4 @@
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';
@ -13,64 +12,35 @@ export default function App() {
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 [notification, setNotification] = useState<{ msg: string, type: 'info' | 'error' } | null>(null);
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 broccoliItems = useMemo(() => {
if (!(theme === '420' && showBroccoli)) return [] as Array<{top:number; left:number; duration:number; delay:number}>;
const items: Array<{top:number; left:number; duration:number; delay:number}> = [];
for (let i = 0; i < 20; i += 1) {
items.push({
top: 5 + Math.random() * 90, // 5%..95%
left: 2 + Math.random() * 96, // 2%..98%
duration: 14 + Math.random() * 14, // 14s..28s
delay: Math.random() * 2 // 0..2s
});
}
return items;
}, [theme, showBroccoli]);
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>('');
// Chaos Mode (Partymode)
const [chaosMode, setChaosMode] = useState<boolean>(false);
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
const chaosTimeoutRef = useRef<number | null>(null);
const chaosModeRef = useRef<boolean>(false);
// Scrolled State for Header blur
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
useEffect(() => { selectedRef.current = selected; }, [selected]);
const showNotification = (msg: string, type: 'info' | 'error' = 'info') => {
setNotification({ msg, type });
setTimeout(() => setNotification(null), 3000);
};
// ---------------- Init Load ----------------
useEffect(() => {
(async () => {
try {
@ -88,18 +58,20 @@ export default function App() {
}
if (initial) setSelected(initial);
} catch (e: any) {
setError(e?.message || 'Fehler beim Laden der Channels');
showNotification(e?.message || 'Fehler beim Laden der Channels', 'error');
}
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)
// ---------------- Theme ----------------
useEffect(() => {
document.body.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
// ---------------- SSE Events ----------------
useEffect(() => {
const unsub = subscribeEvents((msg) => {
if (msg?.type === 'party') {
@ -124,8 +96,7 @@ export default function App() {
const cur = selectedRef.current || '';
const gid = cur ? cur.split(':')[0] : '';
if (gid && typeof vols[gid] === 'number') {
const v = vols[gid];
setVolume(v);
setVolume(vols[gid]);
}
} catch { }
} else if (msg?.type === 'channel') {
@ -153,89 +124,39 @@ export default function App() {
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);
}, []);
// ---------------- Data Fetching ----------------
useEffect(() => {
(async () => {
try {
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, fuzzy);
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, false); // Removed fuzzy param for cleaner UI
setSounds(s.items);
setTotal(s.total);
setFolders(s.folders);
} catch (e: any) {
setError(e?.message || 'Fehler beim Laden der Sounds');
showNotification(e?.message || 'Fehler beim Laden der Sounds', 'error');
}
})();
}, [activeFolder, query, activeCategoryId, fuzzy]);
}, [activeFolder, query, activeCategoryId]);
// Favoriten aus Cookie laden
useEffect(() => {
const c = getCookie('favs');
if (c) {
try { setFavs(JSON.parse(c)); } catch {}
}
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);
@ -245,36 +166,23 @@ export default function App() {
})();
}, [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({});
}
// ---------------- Actions ----------------
async function handlePlay(name: string, rel?: string) {
setError(null);
if (!selected) return setError('Bitte einen Voice-Channel auswählen');
if (!selected) return showNotification('Bitte einen Voice-Channel auswählen', 'error');
const [guildId, channelId] = selected.split(':');
try {
setLoading(true);
await playSound(name, guildId, channelId, volume, rel);
} catch (e: any) {
setError(e?.message || 'Play fehlgeschlagen');
showNotification(e?.message || 'Wiedergabe fehlgeschlagen', 'error');
} finally {
setLoading(false);
}
}
// CHAOS Mode Funktionen (zufällige Wiedergabe alle 1-3 Minuten)
// Chaos Mode Logic
const startChaosMode = async () => {
if (!selected || !sounds.length) return;
const playRandomSound = async () => {
const pool = sounds;
if (!pool.length || !selected) return;
@ -282,9 +190,7 @@ export default function App() {
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);
}
} catch (e: any) { console.error('Chaos sound play failed:', e); }
};
const scheduleNextPlay = async () => {
@ -294,9 +200,7 @@ export default function App() {
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);
};
@ -306,15 +210,9 @@ export default function App() {
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);
}
try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
}
};
@ -322,502 +220,234 @@ export default function App() {
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);
}
// Filter Data
const filtered = sounds;
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
// Scroll Handler for Top Bar Blur
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
setIsScrolled(e.currentTarget.scrollTop > 20);
};
}, []);
return (
<ErrorBoundary>
<div className="page-container" data-theme={theme}>
{/* Floating Broccoli for 420 Theme */}
{theme === '420' && showBroccoli && (
<>
{broccoliItems.map((b, idx) => (
<div
key={idx}
className="broccoli"
style={{ top: `${b.top}%`, left: `${b.left}%`, animationDuration: `${b.duration}s`, animationDelay: `${b.delay}s` }}
>
🥦
</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>
)}
<div className="app-layout">
{/* ---------------- Sidebar ---------------- */}
<aside className="sidebar">
<h1 className="title-large" style={{ marginLeft: 12, marginBottom: 32, fontSize: 28, background: 'linear-gradient(45deg, var(--accent-blue), #5e5ce6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
Jukebox
</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>
<div className="sidebar-title">Library</div>
<button className={`nav-item ${activeFolder === '__all__' ? 'active' : ''}`} onClick={() => setActiveFolder('__all__')}>
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>library_music</span>All Sounds</div>
<span style={{ opacity: 0.5, fontSize: 12 }}>{total}</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 className={`nav-item ${activeFolder === '__favs__' ? 'active' : ''}`} onClick={() => setActiveFolder('__favs__')}>
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>favorite</span>Favorites</div>
<span style={{ opacity: 0.5, fontSize: 12 }}>{favCount}</span>
</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 ? (
<button className={`nav-item ${activeFolder === '__recent__' ? 'active' : ''}`} onClick={() => setActiveFolder('__recent__')}>
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>schedule</span>Recently Added</div>
</button>
{folders.length > 3 && ( // 3 = __all__, __recent__, __top3__
<>
<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=> {
<div className="sidebar-title" style={{ marginTop: 32 }}>Folders</div>
{folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)).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 key={f.key} className={`nav-item ${activeFolder === f.key ? 'active' : ''}`} onClick={() => setActiveFolder(f.key)}>
<div className="flex items-center gap-2" style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}><span className="material-icons" style={{ fontSize: 20 }}>folder</span>{displayName}</div>
<span style={{ opacity: 0.5, fontSize: 12 }}>{f.count}</span>
</button>
);
})}
</div>
</>
)}
{categories.length > 0 && (
<div className="flex flex-wrap gap-3 text-sm mt-3">
<>
<div className="sidebar-title" style={{ marginTop: 32 }}>Categories</div>
{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 key={cat.id} className={`nav-item ${activeCategoryId === cat.id ? 'active' : ''}`} onClick={() => setActiveCategoryId(prev => prev === cat.id ? '' : cat.id)}>
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>label</span>{cat.name}</div>
</button>
))}
</div>
</>
)}
</aside>
{/* ---------------- Main Content ---------------- */}
<main className="main-content" onScroll={handleScroll}>
<header className={`top-bar ${isScrolled ? 'scrolled' : ''}`}>
<h2 className="title-large" style={{ marginBottom: 0 }}>
{activeFolder === '__all__' ? 'All Sounds' :
activeFolder === '__favs__' ? 'Favorites' :
activeFolder === '__recent__' ? 'Recently Added' :
folders.find(f => f.key === activeFolder)?.name.replace(/\s*\(\d+\)\s*$/, '') || 'Library'}
</h2>
<div className="header-actions">
<div className="search-box">
<span className="material-icons search-icon">search</span>
<input
type="text"
className="input-modern"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<main className="sounds-flow">
<select
className="select-modern"
value={theme}
onChange={(e) => setTheme(e.target.value)}
style={{ paddingRight: 20, paddingLeft: 12 }}
>
<option value="dark">Dark Theme</option>
<option value="light">Light Theme</option>
</select>
</div>
</header>
<div className="track-container">
<div className="track-grid">
{(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 key={key} className="track-card" onClick={() => handlePlay(s.name, s.relativePath)}>
<button
className={`fav-btn ${isFav ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setFavs(prev => ({ ...prev, [key]: !prev[key] })); }}
>
<span className="material-icons">{isFav ? 'star' : 'star_border'}</span>
</button>
<div className="track-icon">
<span className="material-icons" style={{ fontSize: 32 }}>music_note</span>
</div>
<div className="track-name">{s.name}</div>
{Array.isArray((s as any).badges) && (s as any).badges!.includes('new') && (
<div className="badge-new">NEW</div>
)}
<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>
);
})}
</div>
</div>
</main>
{/* Footer intentionally left without version display */}
{/* ---------------- Bottom Control Bar ---------------- */}
<div className="bottom-player">
<div className="player-section">
{/* Target Channel */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-icons" style={{ color: 'var(--text-secondary)' }}>headset_mic</span>
<select
className="select-modern"
value={selected}
onChange={async (e) => {
const v = e.target.value;
setSelected(v);
try {
const [gid, cid] = v.split(':');
await setSelectedChannel(gid, cid);
} catch { }
}}
style={{ width: '240px', background: 'transparent', border: '1px solid var(--border-color)' }}
>
<option value="" disabled>Select Channel...</option>
{channels.map((c) => (
<option key={`${c.guildId}:${c.channelId}`} value={`${c.guildId}:${c.channelId}`}>
{c.guildName} {c.channelName}
</option>
))}
</select>
</div>
</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}>
<div className="player-section center">
{/* Playback Controls */}
<button
ref={triggerRef}
type="button"
className="select-trigger"
onClick={() => setOpen(v => !v)}
title={current ? `Channel: ${current.guildName} ${current.channelName}` : 'Channel wählen'}
className="btn-danger"
onClick={async () => {
setChaosMode(false); await stopChaosMode();
if (selected) { const [guildId] = selected.split(':'); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } }
}}
title="Stop All"
>
{current ? `Channel: ${current.guildName} ${current.channelName}` : 'Channel wählen'}
<span className="chev"></span>
<span className="material-icons" style={{ fontSize: 20 }}>stop</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); }}
className="btn-primary"
onClick={async () => {
try {
const items = sounds;
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 { }
}}
title="Play Random Sound"
style={{ padding: '12px 24px', display: 'flex', alignItems: 'center', gap: 6 }}
>
{c.guildName} {c.channelName}
<span className="material-icons" style={{ fontSize: 20 }}>shuffle</span> Shuffle
</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>
<button
className={`btn-icon ${chaosMode ? 'btn-chaos' : ''}`}
onClick={toggleChaosMode}
title="Partymode (Auto-Play)"
style={chaosMode ? { width: 'auto', padding: '0 16px', borderRadius: 999, display: 'flex', gap: 6 } : {}}
>
<span className="material-icons" style={{ fontSize: 20 }}>{chaosMode ? 'celebration' : 'all_inclusive'}</span>
{chaosMode && <span>Party Active</span>}
</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' }}>
<div className="player-section right">
{/* Volume */}
<div className="volume-container">
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_down</span>
<input
value={val}
onChange={(e) => setVal(e.target.value)}
placeholder="Neuer Name"
onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
style={{ color: '#000000' }}
className="volume-slider"
type="range"
min={0} max={1} step={0.01}
value={volume}
onChange={async (e) => {
const v = parseFloat(e.target.value);
setVolume(v);
try { (e.target as HTMLInputElement).style.setProperty('--_fill', `${Math.round(v * 100)}%`); } catch { }
if (selected) { const [guildId] = selected.split(':'); try { await setVolumeLive(guildId, v); } catch { } }
}}
style={{ ['--_fill' as any]: `${Math.round(volume * 100)}%` }}
/>
<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>
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_up</span>
</div>
</div>
</div>
{notification && (
<div className={`notification ${notification.type}`}>
<span className="material-icons" style={{ fontSize: 18 }}>
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
</span>
{notification.msg}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load diff