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:
parent
c3ac4432ca
commit
41d2c0e570
8 changed files with 3568 additions and 2237 deletions
1
web/dist/assets/index-BStrUazC.css
vendored
Normal file
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
1
web/dist/assets/index-DEfJ3Ric.css
vendored
1
web/dist/assets/index-DEfJ3Ric.css
vendored
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
202
web/src/App.tsx
202
web/src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2805
web/src/styles.css
2805
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