jukebox-vibe/web/src/App.tsx

695 lines
26 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import {
fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume,
adminStatus, adminLogin, adminLogout, adminDelete,
fetchCategories, partyStart, partyStop, subscribeEvents,
getSelectedChannels, setSelectedChannel,
} from './api';
import type { VoiceChannelInfo, Sound, Category } from './types';
import { getCookie, setCookie } from './cookies';
2025-08-07 23:24:56 +02:00
const THEMES = [
{ id: 'default', color: '#5865f2', label: 'Discord' },
{ id: 'purple', color: '#9b59b6', label: 'Midnight' },
{ id: 'forest', color: '#2ecc71', label: 'Forest' },
{ id: 'sunset', color: '#e67e22', label: 'Sunset' },
{ id: 'ocean', color: '#3498db', label: 'Ocean' },
];
const CAT_PALETTE = [
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
'#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16',
'#d946ef', '#0ea5e9', '#f43f5e', '#10b981',
];
type Tab = 'all' | 'favorites' | 'recent';
2025-08-07 23:24:56 +02:00
export default function App() {
/* ── Data ── */
2025-08-07 23:24:56 +02:00
const [sounds, setSounds] = useState<Sound[]>([]);
const [total, setTotal] = useState(0);
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
const [categories, setCategories] = useState<Category[]>([]);
/* ── Navigation ── */
const [activeTab, setActiveTab] = useState<Tab>('all');
const [activeFolder, setActiveFolder] = useState('');
2025-08-07 23:24:56 +02:00
const [query, setQuery] = useState('');
/* ── Channels ── */
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
const [selected, setSelected] = useState('');
const selectedRef = useRef('');
const [channelOpen, setChannelOpen] = useState(false);
/* ── Playback ── */
const [volume, setVolume] = useState(1);
const [lastPlayed, setLastPlayed] = useState('');
/* ── Preferences ── */
const [favs, setFavs] = useState<Record<string, boolean>>({});
const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default');
const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110'));
/* ── Party ── */
const [chaosMode, setChaosMode] = useState(false);
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
const chaosModeRef = useRef(false);
/* ── Admin ── */
const [isAdmin, setIsAdmin] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
/* ── UI ── */
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
const [clock, setClock] = useState('');
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
/* ── Refs ── */
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
useEffect(() => { selectedRef.current = selected; }, [selected]);
2025-08-07 23:24:56 +02:00
/* ── Helpers ── */
const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => {
setNotification({ msg, type });
setTimeout(() => setNotification(null), 3000);
}, []);
const guildId = selected ? selected.split(':')[0] : '';
const channelId = selected ? selected.split(':')[1] : '';
const selectedChannel = useMemo(() =>
channels.find(c => `${c.guildId}:${c.channelId}` === selected),
[channels, selected]);
/* ── Clock ── */
useEffect(() => {
const update = () => {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
setClock(`${h}:${m}:${s}`);
};
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, []);
/* ── Init ── */
2025-08-07 23:24:56 +02:00
useEffect(() => {
(async () => {
try {
const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]);
setChannels(ch);
if (ch.length) {
const g = ch[0].guildId;
const serverCid = selMap[g];
const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid);
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
}
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
try { setIsAdmin(await adminStatus()); } catch { }
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
2025-08-07 23:24:56 +02:00
})();
}, []);
2025-08-07 23:24:56 +02:00
/* ── Theme ── */
useEffect(() => {
if (theme === 'default') document.body.removeAttribute('data-theme');
else document.body.setAttribute('data-theme', theme);
localStorage.setItem('jb-theme', theme);
}, [theme]);
/* ── Card size ── */
useEffect(() => {
const r = document.documentElement;
r.style.setProperty('--card-size', cardSize + 'px');
const ratio = cardSize / 110;
r.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px');
r.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px');
localStorage.setItem('jb-card-size', String(cardSize));
}, [cardSize]);
/* ── SSE ── */
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 g = selectedRef.current?.split(':')[0];
if (g && sel[g]) setSelected(`${g}:${sel[g]}`);
} catch { }
try {
const vols = msg?.volumes || {};
const g = selectedRef.current?.split(':')[0];
if (g && typeof vols[g] === 'number') setVolume(vols[g]);
} catch { }
} else if (msg?.type === 'channel') {
const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
} else if (msg?.type === 'volume') {
const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume);
}
});
return () => { try { unsub(); } catch { } };
}, []);
useEffect(() => {
setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false);
}, [selected, partyActiveGuilds]);
/* ── Data Fetch ── */
useEffect(() => {
(async () => {
2025-08-07 23:24:56 +02:00
try {
let folderParam = '__all__';
if (activeTab === 'recent') folderParam = '__recent__';
else if (activeFolder) folderParam = activeFolder;
const s = await fetchSounds(query, folderParam, undefined, false);
setSounds(s.items);
setTotal(s.total);
setFolders(s.folders);
} catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); }
})();
}, [activeTab, activeFolder, query, refreshKey]);
2025-08-07 23:24:56 +02:00
/* ── Favs persistence ── */
useEffect(() => {
const c = getCookie('favs');
if (c) try { setFavs(JSON.parse(c)); } catch { }
}, []);
useEffect(() => {
try { setCookie('favs', JSON.stringify(favs)); } catch { }
}, [favs]);
/* ── Volume sync ── */
useEffect(() => {
if (selected) {
(async () => {
try { const v = await getVolume(guildId); setVolume(v); } catch { }
})();
}
}, [selected]);
/* ── Close dropdowns on outside click ── */
useEffect(() => {
const handler = () => { setChannelOpen(false); setCtxMenu(null); };
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
/* ── Actions ── */
async function handlePlay(s: Sound) {
if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error');
2025-08-07 23:24:56 +02:00
try {
await playSound(s.name, guildId, channelId, volume, s.relativePath);
setLastPlayed(s.name);
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
2025-08-07 23:24:56 +02:00
}
async function handleStop() {
if (!selected) return;
setLastPlayed('');
try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
}
async function handleRandom() {
if (!displaySounds.length || !selected) return;
const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)];
handlePlay(rnd);
}
async function toggleParty() {
if (chaosMode) {
await handleStop();
try { await partyStop(guildId); } catch { }
} else {
if (!selected) return notify('Bitte einen Channel auswählen', 'error');
try { await partyStart(guildId, channelId); } catch { }
}
}
async function handleChannelSelect(ch: VoiceChannelInfo) {
const v = `${ch.guildId}:${ch.channelId}`;
setSelected(v);
setChannelOpen(false);
try { await setSelectedChannel(ch.guildId, ch.channelId); } catch { }
}
function toggleFav(key: string) {
setFavs(prev => ({ ...prev, [key]: !prev[key] }));
}
async function handleAdminLogin() {
try {
const ok = await adminLogin(adminPwd);
if (ok) { setIsAdmin(true); setAdminPwd(''); notify('Admin eingeloggt'); }
else notify('Falsches Passwort', 'error');
} catch { notify('Login fehlgeschlagen', 'error'); }
}
async function handleAdminLogout() {
try { await adminLogout(); setIsAdmin(false); notify('Ausgeloggt'); } catch { }
}
/* ── Computed ── */
const displaySounds = useMemo(() => {
if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
return sounds;
}, [sounds, activeTab, favs]);
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
const visibleFolders = useMemo(() =>
folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)),
[folders]);
const folderColorMap = useMemo(() => {
const m: Record<string, string> = {};
visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; });
return m;
}, [visibleFolders]);
const firstOfInitial = useMemo(() => {
const seen = new Set<string>();
const result = new Set<number>();
displaySounds.forEach((s, idx) => {
const ch = s.name.charAt(0).toUpperCase();
if (!seen.has(ch)) { seen.add(ch); result.add(idx); }
});
return result;
}, [displaySounds]);
const channelsByGuild = useMemo(() => {
const groups: Record<string, VoiceChannelInfo[]> = {};
channels.forEach(c => {
if (!groups[c.guildName]) groups[c.guildName] = [];
groups[c.guildName].push(c);
});
return groups;
}, [channels]);
const clockMain = clock.slice(0, 5);
const clockSec = clock.slice(5);
/*
RENDER
*/
2025-08-07 23:24:56 +02:00
return (
<div className="app">
{chaosMode && <div className="party-overlay active" />}
{/* ═══ TOPBAR ═══ */}
<header className="topbar">
<div className="topbar-left">
<div className="app-logo">
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span>
</div>
<span className="app-title">Soundboard</span>
{/* Channel Dropdown */}
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
<button
className={`channel-btn ${channelOpen ? 'open' : ''}`}
onClick={() => setChannelOpen(!channelOpen)}
>
<span className="material-icons cb-icon">headset</span>
{selected && <span className="channel-status" />}
<span className="channel-label">{selectedChannel?.channelName || 'Channel...'}</span>
<span className={`material-icons chevron`}>expand_more</span>
</button>
{channelOpen && (
<div className="channel-menu visible">
{Object.entries(channelsByGuild).map(([guild, chs]) => (
<React.Fragment key={guild}>
<div className="channel-menu-header">{guild}</div>
{chs.map(ch => (
<div
key={`${ch.guildId}:${ch.channelId}`}
className={`channel-option ${`${ch.guildId}:${ch.channelId}` === selected ? 'active' : ''}`}
onClick={() => handleChannelSelect(ch)}
>
<span className="material-icons co-icon">volume_up</span>
{ch.channelName}
</div>
))}
</React.Fragment>
))}
{channels.length === 0 && (
<div className="channel-option" style={{ color: 'var(--text-faint)', cursor: 'default' }}>
Keine Channels verfügbar
</div>
)}
</div>
)}
</div>
</div>
<div className="clock-wrap">
<div className="clock">{clockMain}<span className="clock-seconds">{clockSec}</span></div>
</div>
<div className="topbar-right">
{selected && (
<div className="connection">
<span className="conn-dot" />
Verbunden
</div>
)}
<button
className={`admin-btn-icon ${isAdmin ? 'active' : ''}`}
onClick={() => setShowAdmin(true)}
title="Admin"
>
<span className="material-icons">settings</span>
</button>
</div>
</header>
{/* ═══ TOOLBAR ═══ */}
<div className="toolbar">
<div className="cat-tabs">
<button
className={`cat-tab ${activeTab === 'all' ? 'active' : ''}`}
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
>
Alle
<span className="tab-count">{total}</span>
</button>
<button
className={`cat-tab ${activeTab === 'recent' ? 'active' : ''}`}
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
>
Neu hinzugefügt
</button>
<button
className={`cat-tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
>
Favoriten
{favCount > 0 && <span className="tab-count">{favCount}</span>}
</button>
</div>
<div className="search-wrap">
<span className="material-icons search-icon">search</span>
<input
className="search-input"
type="text"
placeholder="Suchen..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
{query && (
<button className="search-clear" onClick={() => setQuery('')}>
<span className="material-icons" style={{ fontSize: 14 }}>close</span>
</button>
)}
</div>
<div className="toolbar-spacer" />
<div className="volume-control">
<span
className="material-icons vol-icon"
onClick={() => {
const newVol = volume > 0 ? 0 : 0.5;
setVolume(newVol);
if (guildId) setVolumeLive(guildId, newVol).catch(() => {});
}}
>
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
</span>
<input
type="range"
className="vol-slider"
min={0}
max={1}
step={0.01}
value={volume}
onChange={async e => {
const v = parseFloat(e.target.value);
setVolume(v);
if (guildId) try { await setVolumeLive(guildId, v); } catch { }
}}
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
/>
<span className="vol-pct">{Math.round(volume * 100)}%</span>
</div>
<button className="tb-btn random" onClick={handleRandom} title="Zufälliger Sound">
<span className="material-icons tb-icon">shuffle</span>
Random
</button>
<button
className={`tb-btn party ${chaosMode ? 'active' : ''}`}
onClick={toggleParty}
title="Party Mode"
>
<span className="material-icons tb-icon">{chaosMode ? 'celebration' : 'auto_awesome'}</span>
{chaosMode ? 'Party!' : 'Party'}
</button>
<button className="tb-btn stop" onClick={handleStop} title="Alle stoppen">
<span className="material-icons tb-icon">stop</span>
Stop
</button>
<div className="size-control" title="Button-Größe">
<span className="material-icons sc-icon">grid_view</span>
<input
type="range"
className="size-slider"
min={80}
max={160}
value={cardSize}
onChange={e => setCardSize(parseInt(e.target.value))}
/>
</div>
<div className="theme-selector">
{THEMES.map(t => (
<div
key={t.id}
className={`theme-dot ${theme === t.id ? 'active' : ''}`}
style={{ background: t.color }}
title={t.label}
onClick={() => setTheme(t.id)}
/>
))}
</div>
</div>
{/* ═══ FOLDER CHIPS ═══ */}
{activeTab === 'all' && visibleFolders.length > 0 && (
<div className="category-strip">
{visibleFolders.map(f => {
const color = folderColorMap[f.key] || '#888';
const isActive = activeFolder === f.key;
return (
<button
key={f.key}
className={`cat-chip ${isActive ? 'active' : ''}`}
onClick={() => setActiveFolder(isActive ? '' : f.key)}
style={isActive ? { borderColor: color, color } : undefined}
>
<span className="cat-dot" style={{ background: color }} />
{f.name.replace(/\s*\(\d+\)\s*$/, '')}
<span className="cat-count">{f.count}</span>
</button>
);
})}
</div>
)}
{/* ═══ MAIN ═══ */}
<main className="main">
{displaySounds.length === 0 ? (
<div className="empty-state visible">
<div className="empty-emoji">{activeTab === 'favorites' ? '⭐' : '🔇'}</div>
<div className="empty-title">
{activeTab === 'favorites'
? 'Noch keine Favoriten'
: query
? `Kein Sound für "${query}" gefunden`
: 'Keine Sounds vorhanden'}
</div>
<div className="empty-desc">
{activeTab === 'favorites'
? 'Klick den Stern auf einem Sound!'
: 'Hier gibt\'s noch nichts zu hören.'}
</div>
</div>
) : (
<div className="sound-grid">
{displaySounds.map((s, idx) => {
const key = s.relativePath ?? s.fileName;
const isFav = !!favs[key];
const isPlaying = lastPlayed === s.name;
const isNew = s.isRecent || s.badges?.includes('new');
const initial = s.name.charAt(0).toUpperCase();
const showInitial = firstOfInitial.has(idx);
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
return (
<div
key={key}
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
onClick={e => {
const card = e.currentTarget;
const rect = card.getBoundingClientRect();
const ripple = document.createElement('div');
ripple.className = 'ripple';
const sz = Math.max(rect.width, rect.height);
ripple.style.width = ripple.style.height = sz + 'px';
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
card.appendChild(ripple);
setTimeout(() => ripple.remove(), 500);
handlePlay(s);
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 140),
sound: s,
});
}}
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
>
{isNew && <span className="new-badge">NEU</span>}
<span
className={`fav-star ${isFav ? 'active' : ''}`}
onClick={e => { e.stopPropagation(); toggleFav(key); }}
>
<span className="material-icons fav-icon">{isFav ? 'star' : 'star_border'}</span>
</span>
{showInitial && <span className="sound-emoji" style={{ color: folderColor }}>{initial}</span>}
<span className="sound-name">{s.name}</span>
{s.folder && <span className="sound-duration">{s.folder}</span>}
<div className="playing-indicator">
<div className="wave-bar" /><div className="wave-bar" />
<div className="wave-bar" /><div className="wave-bar" />
</div>
</div>
);
})}
</div>
)}
</main>
{/* ═══ BOTTOM BAR ═══ */}
<div className="bottombar">
<div className="now-playing">
<div className={`np-waves ${lastPlayed ? 'active' : ''}`}>
<div className="np-wave-bar" /><div className="np-wave-bar" />
<div className="np-wave-bar" /><div className="np-wave-bar" />
</div>
<span className="np-label">Spielt:</span>
<span className="np-name">{lastPlayed || '—'}</span>
</div>
</div>
{/* ═══ CONTEXT MENU ═══ */}
{ctxMenu && (
<div
className="ctx-menu visible"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onClick={e => e.stopPropagation()}
>
<div className="ctx-item" onClick={() => { handlePlay(ctxMenu.sound); setCtxMenu(null); }}>
<span className="material-icons ctx-icon">play_arrow</span>
Abspielen
</div>
<div className="ctx-item" onClick={() => {
toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName);
setCtxMenu(null);
}}>
<span className="material-icons ctx-icon">
{favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'}
</span>
Favorit
</div>
{isAdmin && (
<>
<div className="ctx-sep" />
<div className="ctx-item danger" onClick={async () => {
const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName;
try {
await adminDelete([path]);
notify('Sound gelöscht');
setRefreshKey(k => k + 1);
} catch { notify('Löschen fehlgeschlagen', 'error'); }
setCtxMenu(null);
}}>
<span className="material-icons ctx-icon">delete</span>
Löschen
</div>
</>
)}
</div>
)}
{/* ═══ TOAST ═══ */}
{notification && (
<div className={`toast ${notification.type}`}>
<span className="material-icons toast-icon">
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
</span>
{notification.msg}
</div>
)}
{/* ═══ ADMIN PANEL ═══ */}
{showAdmin && (
<div className="admin-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAdmin(false); }}>
<div className="admin-panel">
<h3>
Admin
<button className="admin-close" onClick={() => setShowAdmin(false)}>
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
</button>
</h3>
{!isAdmin ? (
<div>
<div className="admin-field">
<label>Passwort</label>
<input
type="password"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
placeholder="Admin-Passwort..."
/>
</div>
<button className="admin-btn-action primary" onClick={handleAdminLogin}>Login</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Eingeloggt als Admin</p>
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
</div>
)}
</div>
</div>
)}
</div>
);
}