jukebox-vibe/web/src/App.tsx

374 lines
18 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 } from './api';
import type { VoiceChannelInfo, Sound } 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 [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<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 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>('');
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]);
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 (
<ErrorBoundary>
<div className="container mx-auto">
<header className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8">
<div>
<h1 className="text-4xl sm:text-5xl font-black gradient-text">Soundboard Profis</h1>
<p className="text-6xl sm:text-8xl font-bold mt-1" style={{color:'var(--text-primary)'}}>{clock}</p>
</div>
<div className="flex items-center space-x-3 mt-4 sm:mt-0">
<div className="text-right">
<span className="text-sm block" style={{color:'var(--text-secondary)'}}>Geladene Sounds</span>
<span className="text-xl font-bold" style={{color:'var(--text-primary)'}}>{total}</span>
<span className="text-xs block" style={{color:'var(--text-secondary)'}}>Gesamt abgespielt: {totalPlays}</span>
</div>
<button className="bg-[var(--accent-blue)] text-white hover:bg-opacity-90 font-semibold py-2 px-5 rounded-full transition-all" 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="bg-red-600 text-white hover:bg-red-700 font-semibold py-2 px-5 rounded-full transition-all" onClick={async () => { if (!selected) return; const [guildId] = selected.split(':'); await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method:'POST' }); }}>Panik</button>
</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="relative">
<input className="input-field pl-10" 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 className="relative">
<CustomSelect channels={channels} value={selected} onChange={setSelected} />
<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="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" 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 className="input-field appearance-none pl-10" value={theme} onChange={(e)=>setTheme(e.target.value)}>
<option value="dark">Dark</option>
<option value="rainbow">Rainbow Chaos</option>
<option value="light">Light</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>
</div>
</div>
<div className="mt-6" style={{borderTop:'1px solid var(--border-color)', paddingTop:'1.5rem'}}>
<div className="flex items-center gap-4 justify-end">
{!isAdmin && (
<>
<div className="relative w-full sm:w-auto" style={{maxWidth:'15%'}}>
<input className="input-field pl-10" placeholder="Admin Passwort" type="password" value={adminPwd} onChange={(e)=>setAdminPwd(e.target.value)} />
<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>
</div>
</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=> (
<button key={f.key} className={`tag-btn ${activeFolder===f.key?'active':''}`} onClick={async ()=>{ setActiveFolder(f.key); const resp=await fetchSounds(undefined, f.key); setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders); }}>{f.name} ({f.count})</button>
))}
</div>
</div>
{error && <div className="error">{error}</div>}
{info && <div className="badge" style={{ background:'rgba(34,197,94,.18)', borderColor:'rgba(34,197,94,.35)' }}>{info}</div>}
<main className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{(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-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}</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-green)]" onClick={(e)=>{e.stopPropagation(); handlePlay(s.name, s.relativePath);}}><span className="material-icons text-xl">add_circle_outline</span></button>
<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>
);
})}
</main>
</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)}>
{current ? `${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(); }}
/>
<button type="button" className="tab" onClick={() => void submit()}>Umbenennen</button>
</div>
);
}