feat(web): redesign frontend to Discord-style Soundboard UI
Complete rewrite of App.tsx and styles.css to match the Discord-style soundboard mockup design: - Topbar: logo, custom channel dropdown, centered live clock, connection status - Toolbar: category tabs (Alle/Neu/Favoriten), search, Random/Party/Stop buttons, card size slider, theme color dots (5 themes) - Main: responsive square card grid with initial letter avatars, ripple effects, playing animations, favorite stars, NEW badges - Bottom bar: now-playing wave indicator, volume slider - Context menu on right-click (play, favorite, admin delete) - Party mode overlay, toast notifications, admin panel - All API integration preserved (SSE, channels, volume, play, party) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e0bbe03851
commit
da0ae2b9a6
2 changed files with 1383 additions and 1233 deletions
625
web/src/App.tsx
625
web/src/App.tsx
|
|
@ -1,65 +1,71 @@
|
|||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume,
|
||||
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
|
||||
playUrl, fetchCategories, createCategory, assignCategories, clearBadges,
|
||||
updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents,
|
||||
adminStatus, adminLogin, adminLogout, adminDelete,
|
||||
fetchCategories, partyStart, partyStop, subscribeEvents,
|
||||
getSelectedChannels, setSelectedChannel,
|
||||
} from './api';
|
||||
import type { VoiceChannelInfo, Sound, Category } from './types';
|
||||
import { getCookie, setCookie } from './cookies';
|
||||
|
||||
/* ── Category Color Palette ── */
|
||||
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',
|
||||
];
|
||||
|
||||
const THEMES = [
|
||||
{ id: 'midnight', label: 'Midnight' },
|
||||
{ id: 'daylight', label: 'Daylight' },
|
||||
{ id: 'neon', label: 'Neon' },
|
||||
{ id: 'vapor', label: 'Vapor' },
|
||||
{ id: 'matrix', label: 'Matrix' },
|
||||
];
|
||||
|
||||
type Tab = 'all' | 'favorites' | 'recent';
|
||||
type BtnSize = 'S' | 'M' | 'L';
|
||||
const BTN_SIZES: { id: BtnSize; label: string }[] = [
|
||||
{ id: 'S', label: 'S' },
|
||||
{ id: 'M', label: 'M' },
|
||||
{ id: 'L', label: 'L' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
/* ── State ── */
|
||||
/* ── Data ── */
|
||||
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('');
|
||||
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 [favs, setFavs] = useState<Record<string, boolean>>({});
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'midnight');
|
||||
const [btnSize, setBtnSize] = useState<BtnSize>(() => (localStorage.getItem('jb-btn-size') as BtnSize) || 'M');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
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);
|
||||
|
||||
const [lastPlayed, setLastPlayed] = useState('');
|
||||
/* ── 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]);
|
||||
|
|
@ -74,6 +80,24 @@ export default function App() {
|
|||
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 ── */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
|
@ -94,14 +118,20 @@ export default function App() {
|
|||
|
||||
/* ── Theme ── */
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
if (theme === 'default') document.body.removeAttribute('data-theme');
|
||||
else document.body.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('jb-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
/* ── Button Size ── */
|
||||
/* ── Card size ── */
|
||||
useEffect(() => {
|
||||
localStorage.setItem('jb-btn-size', btnSize);
|
||||
}, [btnSize]);
|
||||
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(() => {
|
||||
|
|
@ -146,14 +176,13 @@ export default function App() {
|
|||
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]);
|
||||
}, [activeTab, activeFolder, query, refreshKey]);
|
||||
|
||||
/* ── Favs persistence ── */
|
||||
useEffect(() => {
|
||||
|
|
@ -174,6 +203,13 @@ export default function App() {
|
|||
}
|
||||
}, [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');
|
||||
|
|
@ -190,8 +226,8 @@ export default function App() {
|
|||
}
|
||||
|
||||
async function handleRandom() {
|
||||
if (!sounds.length || !selected) return;
|
||||
const rnd = sounds[Math.floor(Math.random() * sounds.length)];
|
||||
if (!displaySounds.length || !selected) return;
|
||||
const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)];
|
||||
handlePlay(rnd);
|
||||
}
|
||||
|
||||
|
|
@ -205,11 +241,32 @@ export default function App() {
|
|||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
|
||||
return sounds;
|
||||
}, [sounds, activeTab, favs]);
|
||||
|
||||
|
|
@ -225,109 +282,180 @@ export default function App() {
|
|||
return m;
|
||||
}, [visibleFolders]);
|
||||
|
||||
/* ── Admin State ── */
|
||||
const [adminPwd, setAdminPwd] = useState('');
|
||||
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]);
|
||||
|
||||
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'); }
|
||||
}
|
||||
const clockMain = clock.slice(0, 5);
|
||||
const clockSec = clock.slice(5);
|
||||
|
||||
async function handleAdminLogout() {
|
||||
try { await adminLogout(); setIsAdmin(false); notify('Ausgeloggt'); } catch { }
|
||||
}
|
||||
|
||||
/* ── Render ── */
|
||||
/* ════════════════════════════════════════════
|
||||
RENDER
|
||||
════════════════════════════════════════════ */
|
||||
return (
|
||||
<div className={`app-shell ${chaosMode ? 'party-active' : ''}`} data-btn-size={btnSize}>
|
||||
<div className="app">
|
||||
{chaosMode && <div className="party-overlay active" />}
|
||||
|
||||
{/* ════════ Header ════════ */}
|
||||
<header className="header">
|
||||
<div className="logo">JUKEBOX</div>
|
||||
{/* ═══ 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>
|
||||
|
||||
<div className="header-search">
|
||||
{/* 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="Sound suchen..."
|
||||
placeholder="Suchen..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
{query && (
|
||||
<button className="search-clear" onClick={() => setQuery('')}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
|
||||
<span className="material-icons" style={{ fontSize: 14 }}>close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="header-meta">
|
||||
<div className="sound-count">
|
||||
<strong>{total}</strong> Sounds
|
||||
</div>
|
||||
<div className="toolbar-spacer" />
|
||||
|
||||
<div className="size-toggle" title="Button-Größe">
|
||||
{BTN_SIZES.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`size-opt ${btnSize === s.id ? 'active' : ''}`}
|
||||
onClick={() => setBtnSize(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="tb-btn random" onClick={handleRandom} title="Zufälliger Sound">
|
||||
<span className="material-icons tb-icon">shuffle</span>
|
||||
Random
|
||||
</button>
|
||||
|
||||
<select
|
||||
className="select-clean"
|
||||
value={theme}
|
||||
onChange={e => setTheme(e.target.value)}
|
||||
>
|
||||
{THEMES.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<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={`admin-toggle ${isAdmin ? 'is-admin' : ''}`}
|
||||
onClick={() => setShowAdmin(true)}
|
||||
title="Admin"
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>settings</span>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{/* ════════ Tab Bar ════════ */}
|
||||
<nav className="tab-bar">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'all' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>library_music</span>
|
||||
All Sounds
|
||||
<span className="tab-badge">{total}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'favorites' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>star</span>
|
||||
Favorites
|
||||
{favCount > 0 && <span className="tab-badge">{favCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'recent' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>schedule</span>
|
||||
Recently Added
|
||||
</button>
|
||||
</nav>
|
||||
<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>
|
||||
|
||||
{/* ════════ Category Filter ════════ */}
|
||||
{/* ═══ FOLDER CHIPS ═══ */}
|
||||
{activeTab === 'all' && visibleFolders.length > 0 && (
|
||||
<div className="category-strip">
|
||||
{visibleFolders.map(f => {
|
||||
|
|
@ -338,169 +466,185 @@ export default function App() {
|
|||
key={f.key}
|
||||
className={`cat-chip ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setActiveFolder(isActive ? '' : f.key)}
|
||||
style={isActive ? { borderColor: color, color, background: `${color}12` } : undefined}
|
||||
style={isActive ? { borderColor: color, color } : undefined}
|
||||
>
|
||||
<span className="cat-dot" style={{ background: color }} />
|
||||
{f.name.replace(/\s*\(\d+\)\s*$/, '')}
|
||||
<span style={{ opacity: 0.5, fontSize: 10, fontWeight: 700 }}>{f.count}</span>
|
||||
<span className="cat-count">{f.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════════ Sound Grid ════════ */}
|
||||
<div className="sounds-area">
|
||||
{/* ═══ MAIN ═══ */}
|
||||
<main className="main">
|
||||
{displaySounds.length === 0 ? (
|
||||
<div className="sounds-empty">
|
||||
<span className="material-icons">
|
||||
{activeTab === 'favorites' ? 'star_border' : 'music_off'}
|
||||
</span>
|
||||
<p>
|
||||
<div className="empty-state visible">
|
||||
<div className="empty-emoji">{activeTab === 'favorites' ? '⭐' : '🔇'}</div>
|
||||
<div className="empty-title">
|
||||
{activeTab === 'favorites'
|
||||
? 'Noch keine Favorites — klick den Stern!'
|
||||
? 'Noch keine Favoriten'
|
||||
: query
|
||||
? `Kein Sound für "${query}" gefunden`
|
||||
: 'Keine Sounds vorhanden'}
|
||||
</p>
|
||||
</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="sounds-grid">
|
||||
<div className="sound-grid">
|
||||
{displaySounds.map((s, idx) => {
|
||||
const key = s.relativePath ?? s.fileName;
|
||||
const isFav = !!favs[key];
|
||||
const color = s.folder ? folderColorMap[s.folder] || '#555' : '#555';
|
||||
const isNew = s.badges?.includes('new');
|
||||
const isTop = s.badges?.includes('top');
|
||||
const isPlaying = lastPlayed === s.name;
|
||||
const isNew = s.isRecent || s.badges?.includes('new');
|
||||
const initial = s.name.charAt(0).toUpperCase();
|
||||
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={key}
|
||||
className={`sound-btn ${isPlaying ? 'is-playing' : ''}`}
|
||||
onClick={() => handlePlay(s)}
|
||||
className={`sound-card ${isPlaying ? 'playing' : ''}`}
|
||||
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})` : ''}`}
|
||||
style={{ animationDelay: `${Math.min(idx * 8, 400)}ms` }}
|
||||
>
|
||||
<span className="cat-bar" style={{ background: color }} />
|
||||
<span className="sound-label">{s.name}</span>
|
||||
|
||||
{isNew && <span className="badge-dot new" />}
|
||||
{isTop && !isNew && <span className="badge-dot top" />}
|
||||
|
||||
{isNew && <span className="new-badge">NEU</span>}
|
||||
<span
|
||||
className={`fav-star ${isFav ? 'is-fav' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setFavs(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}}
|
||||
className={`fav-star ${isFav ? 'active' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleFav(key); }}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 14 }}>
|
||||
{isFav ? 'star' : 'star_border'}
|
||||
</span>
|
||||
<span className="material-icons fav-icon">{isFav ? 'star' : 'star_border'}</span>
|
||||
</span>
|
||||
</button>
|
||||
<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 className="volume-section">
|
||||
<span
|
||||
className="material-icons volume-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="volume-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="volume-pct">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ════════ Control Bar ════════ */}
|
||||
<div className="control-bar">
|
||||
<div className="ctrl-section left">
|
||||
<div className="channel-wrap">
|
||||
<span className="material-icons">headset_mic</span>
|
||||
<select
|
||||
className="channel-select"
|
||||
value={selected}
|
||||
onChange={async e => {
|
||||
const v = e.target.value;
|
||||
setSelected(v);
|
||||
try {
|
||||
const [g, c] = v.split(':');
|
||||
await setSelectedChannel(g, c);
|
||||
} catch { }
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>Channel...</option>
|
||||
{channels.map(c => (
|
||||
<option key={`${c.guildId}:${c.channelId}`} value={`${c.guildId}:${c.channelId}`}>
|
||||
{c.guildName} · {c.channelName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* ═══ 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>
|
||||
|
||||
{lastPlayed && (
|
||||
<div className="now-playing">
|
||||
<span className="material-icons" style={{ fontSize: 14, verticalAlign: -2, marginRight: 4 }}>play_arrow</span>
|
||||
<span>{lastPlayed}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="ctrl-section center">
|
||||
<button className="ctrl-btn stop" onClick={handleStop} title="Stop">
|
||||
<span className="material-icons">stop</span>
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
|
||||
<button className="ctrl-btn shuffle" onClick={handleRandom} title="Zufälliger Sound">
|
||||
<span className="material-icons">shuffle</span>
|
||||
<span>Random</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`ctrl-btn party ${chaosMode ? 'active' : ''}`}
|
||||
onClick={toggleParty}
|
||||
title="Partymode"
|
||||
>
|
||||
<span className="material-icons">{chaosMode ? 'celebration' : 'auto_awesome'}</span>
|
||||
<span>{chaosMode ? 'Party!' : 'Party'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ctrl-section right">
|
||||
<div className="volume-wrap">
|
||||
<span
|
||||
className="material-icons"
|
||||
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
|
||||
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);
|
||||
if (guildId) try { await setVolumeLive(guildId, v); } catch { }
|
||||
}}
|
||||
style={{ '--fill': `${Math.round(volume * 100)}%` } as React.CSSProperties}
|
||||
/>
|
||||
<span className="volume-pct">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ════════ Notification Toast ════════ */}
|
||||
{/* ═══ TOAST ═══ */}
|
||||
{notification && (
|
||||
<div className={`toast ${notification.type}`}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>
|
||||
<span className="material-icons toast-icon">
|
||||
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
|
||||
</span>
|
||||
{notification.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════════ Admin Panel Overlay ════════ */}
|
||||
{/* ═══ ADMIN PANEL ═══ */}
|
||||
{showAdmin && (
|
||||
<div className="admin-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAdmin(false); }}>
|
||||
<div className="admin-panel">
|
||||
|
|
@ -510,11 +654,10 @@ export default function App() {
|
|||
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div>
|
||||
<div className="admin-field">
|
||||
<label>Password</label>
|
||||
<label>Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPwd}
|
||||
|
|
@ -523,18 +666,12 @@ export default function App() {
|
|||
placeholder="Admin-Passwort..."
|
||||
/>
|
||||
</div>
|
||||
<button className="admin-btn primary" onClick={handleAdminLogin}>
|
||||
Login
|
||||
</button>
|
||||
<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-secondary)' }}>
|
||||
Eingeloggt als Admin
|
||||
</p>
|
||||
<button className="admin-btn outline" onClick={handleAdminLogout}>
|
||||
Logout
|
||||
</button>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Eingeloggt als Admin</p>
|
||||
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
1991
web/src/styles.css
1991
web/src/styles.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue