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

1
web/dist/assets/index-BStrUazC.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
<script type="module" crossorigin src="/assets/index-CG_5yn3u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BStrUazC.css">
</head>
<body>
<div id="root"></div>

View file

@ -46,6 +46,15 @@ export default function App() {
const [adminPassword, setAdminPassword] = useState('');
const [adminError, setAdminError] = useState('');
// Accent theme state
const [accentTheme, setAccentTheme] = useState<string>(() => {
return localStorage.getItem('gaming-hub-accent') || 'ember';
});
useEffect(() => {
localStorage.setItem('gaming-hub-accent', accentTheme);
}, [accentTheme]);
// Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron;
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
@ -206,50 +215,102 @@ export default function App() {
'game-library': '\u{1F3AE}',
};
// Accent swatches configuration
const accentSwatches: { name: string; color: string }[] = [
{ name: 'ember', color: '#e67e22' },
{ name: 'amethyst', color: '#8e44ad' },
{ name: 'ocean', color: '#2e86c1' },
{ name: 'jade', color: '#27ae60' },
{ name: 'rose', color: '#e74c8b' },
{ name: 'crimson', color: '#d63031' },
];
// Find active plugin for display
const activePlugin = plugins.find(p => p.name === activeTab);
return (
<div className="hub-app">
<header className="hub-header">
<div className="hub-header-left">
<span className="hub-logo">{'\u{1F3AE}'}</span>
<span className="hub-title">Gaming Hub</span>
<span className={`hub-conn-dot ${connected ? 'online' : ''}`} />
<div className="app-shell" data-accent={accentTheme}>
{/* ===== SIDEBAR ===== */}
<aside className="app-sidebar">
{/* Sidebar Header: Logo + Brand */}
<div className="sidebar-header">
<div className="app-logo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<rect x="6" y="3" width="12" height="18" rx="2" />
<path d="M9 18h6M12 7v4" />
</svg>
</div>
<span className="app-brand">Gaming Hub</span>
</div>
<nav className="hub-tabs">
{/* Channel Dropdown (static placeholder) */}
<div className="sidebar-channel">
<div className="channel-dropdown-trigger">
<svg className="channel-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" />
</svg>
<span className="channel-name">Sprechstunde</span>
<svg className="channel-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</div>
{/* Section Label */}
<div className="sidebar-section-label">Plugins</div>
{/* Navigation */}
<nav className="sidebar-nav">
{plugins.filter(p => p.name in tabComponents).map(p => (
<button
key={p.name}
className={`hub-tab ${activeTab === p.name ? 'active' : ''}`}
className={`nav-item ${activeTab === p.name ? 'active' : ''}`}
onClick={() => setActiveTab(p.name)}
title={p.description}
>
<span className="hub-tab-icon">{tabIcons[p.name] ?? '\u{1F4E6}'}</span>
<span className="hub-tab-label">{p.name}</span>
<span className="nav-icon">{tabIcons[p.name] || '\u{1F4E6}'}</span>
<span className="nav-label">{p.name}</span>
</button>
))}
</nav>
<div className="hub-header-right">
{!(window as any).electronAPI && (
<a
className="hub-download-btn"
href="/downloads/GamingHub-Setup.exe"
download
title="Desktop App herunterladen"
>
<span className="hub-download-icon">{'\u2B07\uFE0F'}</span>
<span className="hub-download-label">Desktop App</span>
</a>
)}
{/* Accent Theme Picker */}
<div className="sidebar-accent-picker">
{accentSwatches.map(swatch => (
<button
className="hub-refresh-btn"
onClick={() => window.location.reload()}
title="Seite neu laden"
key={swatch.name}
className={`accent-swatch ${accentTheme === swatch.name ? 'active' : ''}`}
style={{ backgroundColor: swatch.color }}
onClick={() => setAccentTheme(swatch.name)}
title={swatch.name.charAt(0).toUpperCase() + swatch.name.slice(1)}
/>
))}
</div>
{/* Sidebar Footer: User + Connection + Settings + Admin */}
<div className="sidebar-footer">
<div className="sidebar-avatar">
D
{connected && <span className={`status-dot ${connected ? 'online' : 'offline'}`} />}
</div>
<div className="sidebar-user-info">
<span className="sidebar-username">User</span>
<span className="sidebar-user-tag">
{connected ? 'Verbunden' : 'Getrennt'}
</span>
</div>
<button
className={`sidebar-settings ${adminLoggedIn ? 'admin-active' : ''}`}
onClick={() => setShowAdminModal(true)}
title="Admin Login"
>
{'\u{1F504}'}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
</button>
<span
className="hub-version hub-version-clickable"
<button
className="sidebar-settings"
onClick={() => {
if (isElectron) {
const api = (window as any).electronAPI;
@ -260,22 +321,50 @@ export default function App() {
}
setShowVersionModal(true);
}}
title="Versionsinformationen"
title="Einstellungen & Version"
>
v{version}
</span>
<button
className={`hub-admin-btn ${adminLoggedIn ? 'logged-in' : ''}`}
onClick={() => setShowAdminModal(true)}
title="Admin Login"
>
{'\u{1F511}'}
{adminLoggedIn && <span className="hub-admin-green-dot" />}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</button>
</div>
</header>
</aside>
{/* ===== MAIN CONTENT ===== */}
<main className="app-main">
<div className="content-area">
{plugins.length === 0 ? (
<div className="hub-empty">
<span className="hub-empty-icon">{'\u{1F4E6}'}</span>
<h2>Keine Plugins geladen</h2>
<p>Plugins werden im Server konfiguriert.</p>
</div>
) : (
/* Render ALL tabs, hide inactive ones to preserve state.
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} isAdmin={adminLoggedIn} />
</div>
);
})
)}
</div>
</main>
{/* ===== VERSION MODAL ===== */}
{showVersionModal && (
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
<div className="hub-version-modal" onClick={e => e.stopPropagation()}>
@ -321,13 +410,13 @@ export default function App() {
{updateStatus === 'checking' && (
<div className="hub-version-modal-update-status">
<span className="hub-update-spinner" />
Suche nach Updates
Suche nach Updates...
</div>
)}
{updateStatus === 'downloading' && (
<div className="hub-version-modal-update-status">
<span className="hub-update-spinner" />
Update wird heruntergeladen
Update wird heruntergeladen...
</div>
)}
{updateStatus === 'ready' && (
@ -367,6 +456,7 @@ export default function App() {
</div>
)}
{/* ===== ADMIN MODAL ===== */}
{showAdminModal && (
<div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}>
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
@ -406,36 +496,6 @@ export default function App() {
</div>
</div>
)}
<main className="hub-content">
{plugins.length === 0 ? (
<div className="hub-empty">
<span className="hub-empty-icon">{'\u{1F4E6}'}</span>
<h2>Keine Plugins geladen</h2>
<p>Plugins werden im Server konfiguriert.</p>
</div>
) : (
/* Render ALL tabs, hide inactive ones to preserve state.
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} isAdmin={adminLoggedIn} />
</div>
);
})
)}
</main>
</div>
);
}

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>
{/* ═══ CONTENT HEADER ═══ */}
<div className="content-header">
<div className="content-header__title">
Soundboard
<span className="sound-count">{totalSoundsDisplay}</span>
</div>
<span className="sb-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 ? `${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>
</div>
</header>
{/* ═══ TOOLBAR ═══ */}
<div className="toolbar">
<div className="cat-tabs">
{/* Filter tabs */}
<button
className={`cat-tab ${activeTab === 'all' ? 'active' : ''}`}
className={`filter-chip ${activeTab === 'all' ? 'active' : ''}`}
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
>
Alle
<span className="tab-count">{total}</span>
<span className="chip-count">{total}</span>
</button>
<button
className={`cat-tab ${activeTab === 'recent' ? 'active' : ''}`}
className={`filter-chip ${activeTab === 'recent' ? 'active' : ''}`}
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
>
Neu hinzugefuegt
</button>
<button
className={`cat-tab ${activeTab === 'favorites' ? 'active' : ''}`}
className={`filter-chip ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
>
Favoriten
{favCount > 0 && <span className="tab-count">{favCount}</span>}
{favCount > 0 && <span className="chip-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__sep" />
{/* URL import */}
<div className="url-import-wrap">
<span className="material-icons url-import-icon">
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
@ -1085,8 +1065,8 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
</button>
</div>
<div className="toolbar-spacer" />
<div className="toolbar__right">
{/* Volume */}
<div className="volume-control">
<span
className="material-icons vol-icon"
@ -1095,12 +1075,13 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
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="vol-slider"
className="volume-slider"
min={0}
max={1}
step={0.01}
@ -1117,30 +1098,49 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
}}
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
/>
<span className="vol-pct">{Math.round(volume * 100)}%</span>
<span className="volume-label">{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>
{/* Channel selector */}
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
<button
className={`tb-btn party ${chaosMode ? 'active' : ''}`}
onClick={toggleParty}
title="Party Mode"
className={`channel-dropdown__trigger ${channelOpen ? 'open' : ''}`}
onClick={() => setChannelOpen(!channelOpen)}
>
<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
<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">grid_view</span>
<span className="material-icons sc-icon" style={{ fontSize: 16 }}>grid_view</span>
<input
type="range"
className="size-slider"
@ -1150,47 +1150,34 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
onChange={e => setCardSize(parseInt(e.target.value))}
/>
</div>
</div>
</div>
<div className="theme-selector">
{THEMES.map(t => (
{/* ═══ 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
key={t.id}
className={`theme-dot ${theme === t.id ? 'active' : ''}`}
style={{ background: t.color }}
title={t.label}
onClick={() => setTheme(t.id)}
/>
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>
<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>
</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>
</div>
</div>
</div>
{/* ═══ FOLDER CHIPS ═══ */}
{activeTab === 'all' && visibleFolders.length > 0 && (
@ -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,9 +1219,29 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
: 'Hier gibt\'s noch nichts zu hoeren.'}
</div>
</div>
) : (
) : (() => {
// 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 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">
{displaySounds.map((s, idx) => {
{group.sounds.map(({ sound: s, globalIdx: idx }) => {
const key = s.relativePath ?? s.fileName;
const isFav = !!favs[key];
const isPlaying = lastPlayed === s.name;
@ -1290,8 +1297,10 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
);
})}
</div>
)}
</main>
</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>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff