Redesign: Sidebar-Layout + neues Design-System "Neon Forge"

- styles.css: Komplettes Design-System v2.0 mit HSL-basierten
  Accent-Themes (Ember, Amethyst, Ocean, Jade, Rose, Crimson),
  Glassmorphism-Tokens, Typography-Scale, Spacing-System
- App.tsx: Sidebar-Navigation statt Top-Tabs, Accent-Picker
  in Sidebar, Channel-Dropdown, User-Panel mit Connection-Status
- SoundboardTab.tsx: Eigener Content-Header mit Search, Playback-
  Controls (Stop/Random/Party), Alphabetische Kategorie-Header,
  Most-Played-Strip mit Rang-Chips, Connection-Badge
- soundboard.css: Alle Styles auf globale Design-Tokens umgestellt,
  neue BEM-Klassen fuer Filter-Chips, Channel-Dropdown, Most-Played

Alle bestehenden Funktionalitaeten bleiben erhalten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 17:54:43 +01:00
parent c3ac4432ca
commit 41d2c0e570
8 changed files with 3568 additions and 2237 deletions

View file

@ -267,14 +267,6 @@ function apiUploadFileWithName(
CONSTANTS
*/
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',
@ -508,7 +500,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ── Theme (persist only, data-theme is set on .sb-app div) ── */
/* ── Theme (persist — global theming now handled by app-shell) ── */
useEffect(() => {
localStorage.setItem('jb-theme', theme);
}, [theme]);
@ -928,78 +920,60 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
RENDER
*/
return (
<div className="sb-app" data-theme={theme} ref={sbAppRef}>
<div className="sb-app" ref={sbAppRef}>
{chaosMode && <div className="party-overlay active" />}
{/* ═══ TOPBAR ═══ */}
<header className="topbar">
<div className="topbar-left">
<div className="sb-app-logo">
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span>
</div>
<span className="sb-app-title">Soundboard</span>
{/* ═══ CONTENT HEADER ═══ */}
<div className="content-header">
<div className="content-header__title">
Soundboard
<span className="sound-count">{totalSoundsDisplay}</span>
</div>
{/* 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 ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
<span className={`material-icons chevron`}>expand_more</span>
<div className="content-header__search">
<span className="material-icons" style={{ fontSize: 14 }}>search</span>
<input
type="text"
placeholder="Suchen..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
{query && (
<button className="search-clear" onClick={() => setQuery('')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center' }}>
<span className="material-icons" style={{ fontSize: 14 }}>close</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}{ch.members ? ` (${ch.members})` : ''}
</div>
))}
</React.Fragment>
))}
{channels.length === 0 && (
<div className="channel-option" style={{ color: 'var(--text-faint)', cursor: 'default' }}>
Keine Channels verfuegbar
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="clock-wrap">
<div className="clock">{clockMain}<span className="clock-seconds">{clockSec}</span></div>
</div>
<div className="topbar-right">
<div className="content-header__actions">
{/* Now Playing indicator */}
{lastPlayed && (
<div className="now-playing">
<div className="np-waves 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">Last Played:</span> <span className="np-name">{lastPlayed}</span>
<span className="np-label">Now:</span> <span className="np-name">{lastPlayed}</span>
</div>
)}
{/* Connection status */}
{selected && (
<div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
<span className="conn-dot" />
<div
className="connection-badge connected"
onClick={() => setShowConnModal(true)}
style={{ cursor: 'pointer' }}
title="Verbindungsdetails"
>
<span className="dot" />
Verbunden
{voiceStats?.voicePing != null && (
<span className="conn-ping">{voiceStats.voicePing}ms</span>
<span className="conn-ping" style={{ marginLeft: 4, fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{voiceStats.voicePing}ms</span>
)}
</div>
)}
{/* Admin button */}
{isAdmin && (
<button
className="admin-btn-icon active"
@ -1009,50 +983,56 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
<span className="material-icons">settings</span>
</button>
)}
{/* Playback controls */}
<div className="playback-controls">
<button className="playback-btn playback-btn--stop" onClick={handleStop} title="Alle stoppen">
<span className="material-icons" style={{ fontSize: 14 }}>stop</span>
Stop
</button>
<button className="playback-btn" onClick={handleRandom} title="Zufaelliger Sound">
<span className="material-icons" style={{ fontSize: 14 }}>shuffle</span>
Random
</button>
<button
className={`playback-btn playback-btn--party ${chaosMode ? 'active' : ''}`}
onClick={toggleParty}
title="Party Mode"
>
<span className="material-icons" style={{ fontSize: 14 }}>{chaosMode ? 'celebration' : 'auto_awesome'}</span>
{chaosMode ? 'Party!' : 'Party'}
</button>
</div>
</div>
</header>
</div>
{/* ═══ 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 hinzugefuegt
</button>
<button
className={`cat-tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
>
Favoriten
{favCount > 0 && <span className="tab-count">{favCount}</span>}
</button>
</div>
{/* Filter tabs */}
<button
className={`filter-chip ${activeTab === 'all' ? 'active' : ''}`}
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
>
Alle
<span className="chip-count">{total}</span>
</button>
<button
className={`filter-chip ${activeTab === 'recent' ? 'active' : ''}`}
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
>
Neu hinzugefuegt
</button>
<button
className={`filter-chip ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
>
Favoriten
{favCount > 0 && <span className="chip-count">{favCount}</span>}
</button>
<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__sep" />
{/* URL import */}
<div className="url-import-wrap">
<span className="material-icons url-import-icon">
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
@ -1085,113 +1065,120 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
</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) apiSetVolumeLive(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={e => {
const v = parseFloat(e.target.value);
setVolume(v);
if (guildId) {
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
volDebounceRef.current = setTimeout(() => {
apiSetVolumeLive(guildId, v).catch(() => {});
}, 120);
}
}}
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="Zufaelliger 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-Groesse">
<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 className="toolbar__right">
{/* Volume */}
<div className="volume-control">
<span
className="material-icons vol-icon"
onClick={() => {
const newVol = volume > 0 ? 0 : 0.5;
setVolume(newVol);
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
}}
style={{ cursor: 'pointer' }}
>
{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={e => {
const v = parseFloat(e.target.value);
setVolume(v);
if (guildId) {
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
volDebounceRef.current = setTimeout(() => {
apiSetVolumeLive(guildId, v).catch(() => {});
}, 120);
}
}}
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
/>
))}
</div>
</div>
<div className="analytics-strip">
<div className="analytics-card">
<span className="material-icons analytics-icon">library_music</span>
<div className="analytics-copy">
<span className="analytics-label">Sounds gesamt</span>
<strong className="analytics-value">{totalSoundsDisplay}</strong>
<span className="volume-label">{Math.round(volume * 100)}%</span>
</div>
</div>
<div className="analytics-card analytics-wide">
<span className="material-icons analytics-icon">leaderboard</span>
<div className="analytics-copy">
<span className="analytics-label">Most Played</span>
<div className="analytics-top-list">
{analyticsTop.length === 0 ? (
<span className="analytics-muted">Noch keine Plays</span>
) : (
analyticsTop.map((item, idx) => (
<span className="analytics-chip" key={item.relativePath}>
{idx + 1}. {item.name} ({item.count})
</span>
))
)}
</div>
{/* Channel selector */}
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
<button
className={`channel-dropdown__trigger ${channelOpen ? 'open' : ''}`}
onClick={() => setChannelOpen(!channelOpen)}
>
<span className="material-icons channel-icon" style={{ fontSize: 16 }}>headset</span>
{selected && <span className="channel-status" style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--success)', flexShrink: 0 }} />}
<span className="channel-name">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
<span className={`material-icons channel-arrow`} style={{ fontSize: 14 }}>expand_more</span>
</button>
{channelOpen && (
<div className="channel-dropdown__menu" style={{ display: 'block' }}>
{Object.entries(channelsByGuild).map(([guild, chs]) => (
<React.Fragment key={guild}>
<div className="channel-menu-header" style={{ padding: '4px 12px', fontSize: 'var(--text-xs)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-tertiary)' }}>{guild}</div>
{chs.map(ch => (
<div
key={`${ch.guildId}:${ch.channelId}`}
className={`channel-dropdown__item ${`${ch.guildId}:${ch.channelId}` === selected ? 'selected' : ''}`}
onClick={() => handleChannelSelect(ch)}
>
<span className="material-icons ch-icon" style={{ fontSize: 14 }}>volume_up</span>
{ch.channelName}{ch.members ? ` (${ch.members})` : ''}
</div>
))}
</React.Fragment>
))}
{channels.length === 0 && (
<div className="channel-dropdown__item" style={{ color: 'var(--text-tertiary)', cursor: 'default' }}>
Keine Channels verfuegbar
</div>
)}
</div>
)}
</div>
{/* Card size slider */}
<div className="size-control" title="Button-Groesse">
<span className="material-icons sc-icon" style={{ fontSize: 16 }}>grid_view</span>
<input
type="range"
className="size-slider"
min={80}
max={160}
value={cardSize}
onChange={e => setCardSize(parseInt(e.target.value))}
/>
</div>
</div>
</div>
{/* ═══ MOST PLAYED / ANALYTICS ═══ */}
{analyticsTop.length > 0 && (
<div className="most-played">
<div className="most-played__label">
<span className="material-icons" style={{ fontSize: 12 }}>leaderboard</span>
Most Played
</div>
<div className="most-played__row">
{analyticsTop.map((item, idx) => (
<div
className="mp-chip"
key={item.relativePath}
onClick={() => {
const found = sounds.find(s => (s.relativePath ?? s.fileName) === item.relativePath);
if (found) handlePlay(found);
}}
>
<span className={`mp-chip__rank ${idx === 0 ? 'gold' : idx === 1 ? 'silver' : idx === 2 ? 'bronze' : ''}`}>{idx + 1}</span>
<span className="mp-chip__name">{item.name}</span>
<span className="mp-chip__plays">{item.count}</span>
</div>
))}
</div>
</div>
)}
{/* ═══ FOLDER CHIPS ═══ */}
{activeTab === 'all' && visibleFolders.length > 0 && (
<div className="category-strip">
@ -1214,8 +1201,8 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
</div>
)}
{/* ═══ MAIN ═══ */}
<main className="main">
{/* ═══ SOUND GRID ═══ */}
<div className="sound-grid-container">
{displaySounds.length === 0 ? (
<div className="empty-state visible">
<div className="empty-emoji">{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}</div>
@ -1232,66 +1219,88 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
: 'Hier gibt\'s noch nichts zu hoeren.'}
</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)';
) : (() => {
// Group sounds by initial letter for category headers
const groups: { letter: string; sounds: { sound: Sound; globalIdx: number }[] }[] = [];
let currentLetter = '';
displaySounds.forEach((s, idx) => {
const ch = s.name.charAt(0).toUpperCase();
const letter = /[A-Z]/.test(ch) ? ch : '#';
if (letter !== currentLetter) {
currentLetter = letter;
groups.push({ letter, sounds: [] });
}
groups[groups.length - 1].sounds.push({ sound: s, globalIdx: idx });
});
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>
return groups.map(group => (
<React.Fragment key={group.letter}>
<div className="category-header">
<span className="category-letter">{group.letter}</span>
<span className="category-count">{group.sounds.length} Sound{group.sounds.length !== 1 ? 's' : ''}</span>
<span className="category-line" />
</div>
<div className="sound-grid">
{group.sounds.map(({ sound: s, globalIdx: 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>
</React.Fragment>
));
})()}
</div>
{/* ═══ CONTEXT MENU ═══ */}
{ctxMenu && (
@ -1658,7 +1667,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
{dropPhase === 'naming' && (
<div className="dl-modal-actions">
<button className="dl-modal-cancel" onClick={handleDropSkip}>
{dropFiles.length > 1 ? 'Überspringen' : 'Abbrechen'}
{dropFiles.length > 1 ? '\u00dcberspringen' : 'Abbrechen'}
</button>
<button className="dl-modal-submit" onClick={() => void handleDropConfirm()}>
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>