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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gaming Hub</title>
|
<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>" />
|
<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>
|
<script type="module" crossorigin src="/assets/index-CG_5yn3u.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BStrUazC.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 [adminPassword, setAdminPassword] = useState('');
|
||||||
const [adminError, setAdminError] = 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
|
// Electron auto-update state
|
||||||
const isElectron = !!(window as any).electronAPI?.isElectron;
|
const isElectron = !!(window as any).electronAPI?.isElectron;
|
||||||
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
|
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
|
||||||
|
|
@ -206,50 +215,102 @@ export default function App() {
|
||||||
'game-library': '\u{1F3AE}',
|
'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 (
|
return (
|
||||||
<div className="hub-app">
|
<div className="app-shell" data-accent={accentTheme}>
|
||||||
<header className="hub-header">
|
{/* ===== SIDEBAR ===== */}
|
||||||
<div className="hub-header-left">
|
<aside className="app-sidebar">
|
||||||
<span className="hub-logo">{'\u{1F3AE}'}</span>
|
{/* Sidebar Header: Logo + Brand */}
|
||||||
<span className="hub-title">Gaming Hub</span>
|
<div className="sidebar-header">
|
||||||
<span className={`hub-conn-dot ${connected ? 'online' : ''}`} />
|
<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>
|
</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 => (
|
{plugins.filter(p => p.name in tabComponents).map(p => (
|
||||||
<button
|
<button
|
||||||
key={p.name}
|
key={p.name}
|
||||||
className={`hub-tab ${activeTab === p.name ? 'active' : ''}`}
|
className={`nav-item ${activeTab === p.name ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab(p.name)}
|
onClick={() => setActiveTab(p.name)}
|
||||||
title={p.description}
|
title={p.description}
|
||||||
>
|
>
|
||||||
<span className="hub-tab-icon">{tabIcons[p.name] ?? '\u{1F4E6}'}</span>
|
<span className="nav-icon">{tabIcons[p.name] || '\u{1F4E6}'}</span>
|
||||||
<span className="hub-tab-label">{p.name}</span>
|
<span className="nav-label">{p.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hub-header-right">
|
{/* Accent Theme Picker */}
|
||||||
{!(window as any).electronAPI && (
|
<div className="sidebar-accent-picker">
|
||||||
<a
|
{accentSwatches.map(swatch => (
|
||||||
className="hub-download-btn"
|
<button
|
||||||
href="/downloads/GamingHub-Setup.exe"
|
key={swatch.name}
|
||||||
download
|
className={`accent-swatch ${accentTheme === swatch.name ? 'active' : ''}`}
|
||||||
title="Desktop App herunterladen"
|
style={{ backgroundColor: swatch.color }}
|
||||||
>
|
onClick={() => setAccentTheme(swatch.name)}
|
||||||
<span className="hub-download-icon">{'\u2B07\uFE0F'}</span>
|
title={swatch.name.charAt(0).toUpperCase() + swatch.name.slice(1)}
|
||||||
<span className="hub-download-label">Desktop App</span>
|
/>
|
||||||
</a>
|
))}
|
||||||
)}
|
</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
|
<button
|
||||||
className="hub-refresh-btn"
|
className={`sidebar-settings ${adminLoggedIn ? 'admin-active' : ''}`}
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => setShowAdminModal(true)}
|
||||||
title="Seite neu laden"
|
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>
|
</button>
|
||||||
<span
|
<button
|
||||||
className="hub-version hub-version-clickable"
|
className="sidebar-settings"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
|
|
@ -260,22 +321,50 @@ export default function App() {
|
||||||
}
|
}
|
||||||
setShowVersionModal(true);
|
setShowVersionModal(true);
|
||||||
}}
|
}}
|
||||||
title="Versionsinformationen"
|
title="Einstellungen & Version"
|
||||||
>
|
>
|
||||||
v{version}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
</span>
|
<circle cx="12" cy="12" r="3" />
|
||||||
<button
|
<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" />
|
||||||
className={`hub-admin-btn ${adminLoggedIn ? 'logged-in' : ''}`}
|
</svg>
|
||||||
onClick={() => setShowAdminModal(true)}
|
|
||||||
title="Admin Login"
|
|
||||||
>
|
|
||||||
{'\u{1F511}'}
|
|
||||||
{adminLoggedIn && <span className="hub-admin-green-dot" />}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{showVersionModal && (
|
||||||
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
|
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
|
||||||
<div className="hub-version-modal" onClick={e => e.stopPropagation()}>
|
<div className="hub-version-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
|
@ -321,13 +410,13 @@ export default function App() {
|
||||||
{updateStatus === 'checking' && (
|
{updateStatus === 'checking' && (
|
||||||
<div className="hub-version-modal-update-status">
|
<div className="hub-version-modal-update-status">
|
||||||
<span className="hub-update-spinner" />
|
<span className="hub-update-spinner" />
|
||||||
Suche nach Updates…
|
Suche nach Updates...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{updateStatus === 'downloading' && (
|
{updateStatus === 'downloading' && (
|
||||||
<div className="hub-version-modal-update-status">
|
<div className="hub-version-modal-update-status">
|
||||||
<span className="hub-update-spinner" />
|
<span className="hub-update-spinner" />
|
||||||
Update wird heruntergeladen…
|
Update wird heruntergeladen...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{updateStatus === 'ready' && (
|
{updateStatus === 'ready' && (
|
||||||
|
|
@ -367,6 +456,7 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ===== ADMIN MODAL ===== */}
|
||||||
{showAdminModal && (
|
{showAdminModal && (
|
||||||
<div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}>
|
<div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}>
|
||||||
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
|
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
|
@ -406,36 +496,6 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -267,14 +267,6 @@ function apiUploadFileWithName(
|
||||||
CONSTANTS
|
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 = [
|
const CAT_PALETTE = [
|
||||||
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
|
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
|
||||||
'#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16',
|
'#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
|
// 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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('jb-theme', theme);
|
localStorage.setItem('jb-theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
@ -928,78 +920,60 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
|
||||||
RENDER
|
RENDER
|
||||||
════════════════════════════════════════════ */
|
════════════════════════════════════════════ */
|
||||||
return (
|
return (
|
||||||
<div className="sb-app" data-theme={theme} ref={sbAppRef}>
|
<div className="sb-app" ref={sbAppRef}>
|
||||||
{chaosMode && <div className="party-overlay active" />}
|
{chaosMode && <div className="party-overlay active" />}
|
||||||
|
|
||||||
{/* ═══ TOPBAR ═══ */}
|
{/* ═══ CONTENT HEADER ═══ */}
|
||||||
<header className="topbar">
|
<div className="content-header">
|
||||||
<div className="topbar-left">
|
<div className="content-header__title">
|
||||||
<div className="sb-app-logo">
|
Soundboard
|
||||||
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span>
|
<span className="sound-count">{totalSoundsDisplay}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="sb-app-title">Soundboard</span>
|
|
||||||
|
|
||||||
{/* Channel Dropdown */}
|
<div className="content-header__search">
|
||||||
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
|
<span className="material-icons" style={{ fontSize: 14 }}>search</span>
|
||||||
<button
|
<input
|
||||||
className={`channel-btn ${channelOpen ? 'open' : ''}`}
|
type="text"
|
||||||
onClick={() => setChannelOpen(!channelOpen)}
|
placeholder="Suchen..."
|
||||||
>
|
value={query}
|
||||||
<span className="material-icons cb-icon">headset</span>
|
onChange={e => setQuery(e.target.value)}
|
||||||
{selected && <span className="channel-status" />}
|
/>
|
||||||
<span className="channel-label">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
|
{query && (
|
||||||
<span className={`material-icons chevron`}>expand_more</span>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div className="clock-wrap">
|
<div className="content-header__actions">
|
||||||
<div className="clock">{clockMain}<span className="clock-seconds">{clockSec}</span></div>
|
{/* Now Playing indicator */}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="topbar-right">
|
|
||||||
{lastPlayed && (
|
{lastPlayed && (
|
||||||
<div className="now-playing">
|
<div className="now-playing">
|
||||||
<div className="np-waves active">
|
<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 className="np-wave-bar" /><div className="np-wave-bar" />
|
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
|
<div
|
||||||
<span className="conn-dot" />
|
className="connection-badge connected"
|
||||||
|
onClick={() => setShowConnModal(true)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="Verbindungsdetails"
|
||||||
|
>
|
||||||
|
<span className="dot" />
|
||||||
Verbunden
|
Verbunden
|
||||||
{voiceStats?.voicePing != null && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Admin button */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="admin-btn-icon active"
|
className="admin-btn-icon active"
|
||||||
|
|
@ -1009,50 +983,56 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
|
||||||
<span className="material-icons">settings</span>
|
<span className="material-icons">settings</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ═══ TOOLBAR ═══ */}
|
{/* ═══ TOOLBAR ═══ */}
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="cat-tabs">
|
{/* Filter tabs */}
|
||||||
<button
|
<button
|
||||||
className={`cat-tab ${activeTab === 'all' ? 'active' : ''}`}
|
className={`filter-chip ${activeTab === 'all' ? 'active' : ''}`}
|
||||||
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
|
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
<span className="tab-count">{total}</span>
|
<span className="chip-count">{total}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`cat-tab ${activeTab === 'recent' ? 'active' : ''}`}
|
className={`filter-chip ${activeTab === 'recent' ? 'active' : ''}`}
|
||||||
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
|
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
|
||||||
>
|
>
|
||||||
Neu hinzugefuegt
|
Neu hinzugefuegt
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`cat-tab ${activeTab === 'favorites' ? 'active' : ''}`}
|
className={`filter-chip ${activeTab === 'favorites' ? 'active' : ''}`}
|
||||||
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
|
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
|
||||||
>
|
>
|
||||||
Favoriten
|
Favoriten
|
||||||
{favCount > 0 && <span className="tab-count">{favCount}</span>}
|
{favCount > 0 && <span className="chip-count">{favCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="search-wrap">
|
<div className="toolbar__sep" />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{/* URL import */}
|
||||||
<div className="url-import-wrap">
|
<div className="url-import-wrap">
|
||||||
<span className="material-icons url-import-icon">
|
<span className="material-icons url-import-icon">
|
||||||
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
|
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
|
||||||
|
|
@ -1085,113 +1065,120 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-spacer" />
|
<div className="toolbar__right">
|
||||||
|
{/* Volume */}
|
||||||
<div className="volume-control">
|
<div className="volume-control">
|
||||||
<span
|
<span
|
||||||
className="material-icons vol-icon"
|
className="material-icons vol-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newVol = volume > 0 ? 0 : 0.5;
|
const newVol = volume > 0 ? 0 : 0.5;
|
||||||
setVolume(newVol);
|
setVolume(newVol);
|
||||||
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
|
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
|
||||||
}}
|
}}
|
||||||
>
|
style={{ cursor: 'pointer' }}
|
||||||
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
|
>
|
||||||
</span>
|
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
|
||||||
<input
|
</span>
|
||||||
type="range"
|
<input
|
||||||
className="vol-slider"
|
type="range"
|
||||||
min={0}
|
className="volume-slider"
|
||||||
max={1}
|
min={0}
|
||||||
step={0.01}
|
max={1}
|
||||||
value={volume}
|
step={0.01}
|
||||||
onChange={e => {
|
value={volume}
|
||||||
const v = parseFloat(e.target.value);
|
onChange={e => {
|
||||||
setVolume(v);
|
const v = parseFloat(e.target.value);
|
||||||
if (guildId) {
|
setVolume(v);
|
||||||
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
|
if (guildId) {
|
||||||
volDebounceRef.current = setTimeout(() => {
|
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
|
||||||
apiSetVolumeLive(guildId, v).catch(() => {});
|
volDebounceRef.current = setTimeout(() => {
|
||||||
}, 120);
|
apiSetVolumeLive(guildId, v).catch(() => {});
|
||||||
}
|
}, 120);
|
||||||
}}
|
}
|
||||||
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
|
}}
|
||||||
/>
|
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)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<span className="volume-label">{Math.round(volume * 100)}%</span>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="analytics-card analytics-wide">
|
{/* Channel selector */}
|
||||||
<span className="material-icons analytics-icon">leaderboard</span>
|
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
|
||||||
<div className="analytics-copy">
|
<button
|
||||||
<span className="analytics-label">Most Played</span>
|
className={`channel-dropdown__trigger ${channelOpen ? 'open' : ''}`}
|
||||||
<div className="analytics-top-list">
|
onClick={() => setChannelOpen(!channelOpen)}
|
||||||
{analyticsTop.length === 0 ? (
|
>
|
||||||
<span className="analytics-muted">Noch keine Plays</span>
|
<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 }} />}
|
||||||
analyticsTop.map((item, idx) => (
|
<span className="channel-name">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
|
||||||
<span className="analytics-chip" key={item.relativePath}>
|
<span className={`material-icons channel-arrow`} style={{ fontSize: 14 }}>expand_more</span>
|
||||||
{idx + 1}. {item.name} ({item.count})
|
</button>
|
||||||
</span>
|
{channelOpen && (
|
||||||
))
|
<div className="channel-dropdown__menu" style={{ display: 'block' }}>
|
||||||
)}
|
{Object.entries(channelsByGuild).map(([guild, chs]) => (
|
||||||
</div>
|
<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>
|
</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 ═══ */}
|
{/* ═══ FOLDER CHIPS ═══ */}
|
||||||
{activeTab === 'all' && visibleFolders.length > 0 && (
|
{activeTab === 'all' && visibleFolders.length > 0 && (
|
||||||
<div className="category-strip">
|
<div className="category-strip">
|
||||||
|
|
@ -1214,8 +1201,8 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══ MAIN ═══ */}
|
{/* ═══ SOUND GRID ═══ */}
|
||||||
<main className="main">
|
<div className="sound-grid-container">
|
||||||
{displaySounds.length === 0 ? (
|
{displaySounds.length === 0 ? (
|
||||||
<div className="empty-state visible">
|
<div className="empty-state visible">
|
||||||
<div className="empty-emoji">{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}</div>
|
<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.'}
|
: 'Hier gibt\'s noch nichts zu hoeren.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
<div className="sound-grid">
|
// Group sounds by initial letter for category headers
|
||||||
{displaySounds.map((s, idx) => {
|
const groups: { letter: string; sounds: { sound: Sound; globalIdx: number }[] }[] = [];
|
||||||
const key = s.relativePath ?? s.fileName;
|
let currentLetter = '';
|
||||||
const isFav = !!favs[key];
|
displaySounds.forEach((s, idx) => {
|
||||||
const isPlaying = lastPlayed === s.name;
|
const ch = s.name.charAt(0).toUpperCase();
|
||||||
const isNew = s.isRecent || s.badges?.includes('new');
|
const letter = /[A-Z]/.test(ch) ? ch : '#';
|
||||||
const initial = s.name.charAt(0).toUpperCase();
|
if (letter !== currentLetter) {
|
||||||
const showInitial = firstOfInitial.has(idx);
|
currentLetter = letter;
|
||||||
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
|
groups.push({ letter, sounds: [] });
|
||||||
|
}
|
||||||
|
groups[groups.length - 1].sounds.push({ sound: s, globalIdx: idx });
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return groups.map(group => (
|
||||||
<div
|
<React.Fragment key={group.letter}>
|
||||||
key={key}
|
<div className="category-header">
|
||||||
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
|
<span className="category-letter">{group.letter}</span>
|
||||||
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
|
<span className="category-count">{group.sounds.length} Sound{group.sounds.length !== 1 ? 's' : ''}</span>
|
||||||
onClick={e => {
|
<span className="category-line" />
|
||||||
const card = e.currentTarget;
|
</div>
|
||||||
const rect = card.getBoundingClientRect();
|
<div className="sound-grid">
|
||||||
const ripple = document.createElement('div');
|
{group.sounds.map(({ sound: s, globalIdx: idx }) => {
|
||||||
ripple.className = 'ripple';
|
const key = s.relativePath ?? s.fileName;
|
||||||
const sz = Math.max(rect.width, rect.height);
|
const isFav = !!favs[key];
|
||||||
ripple.style.width = ripple.style.height = sz + 'px';
|
const isPlaying = lastPlayed === s.name;
|
||||||
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
|
const isNew = s.isRecent || s.badges?.includes('new');
|
||||||
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
|
const initial = s.name.charAt(0).toUpperCase();
|
||||||
card.appendChild(ripple);
|
const showInitial = firstOfInitial.has(idx);
|
||||||
setTimeout(() => ripple.remove(), 500);
|
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
|
||||||
handlePlay(s);
|
|
||||||
}}
|
return (
|
||||||
onContextMenu={e => {
|
<div
|
||||||
e.preventDefault();
|
key={key}
|
||||||
e.stopPropagation();
|
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
|
||||||
setCtxMenu({
|
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
|
||||||
x: Math.min(e.clientX, window.innerWidth - 170),
|
onClick={e => {
|
||||||
y: Math.min(e.clientY, window.innerHeight - 140),
|
const card = e.currentTarget;
|
||||||
sound: s,
|
const rect = card.getBoundingClientRect();
|
||||||
});
|
const ripple = document.createElement('div');
|
||||||
}}
|
ripple.className = 'ripple';
|
||||||
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
|
const sz = Math.max(rect.width, rect.height);
|
||||||
>
|
ripple.style.width = ripple.style.height = sz + 'px';
|
||||||
{isNew && <span className="new-badge">NEU</span>}
|
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
|
||||||
<span
|
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
|
||||||
className={`fav-star ${isFav ? 'active' : ''}`}
|
card.appendChild(ripple);
|
||||||
onClick={e => { e.stopPropagation(); toggleFav(key); }}
|
setTimeout(() => ripple.remove(), 500);
|
||||||
>
|
handlePlay(s);
|
||||||
<span className="material-icons fav-icon">{isFav ? 'star' : 'star_border'}</span>
|
}}
|
||||||
</span>
|
onContextMenu={e => {
|
||||||
{showInitial && <span className="sound-emoji" style={{ color: folderColor }}>{initial}</span>}
|
e.preventDefault();
|
||||||
<span className="sound-name">{s.name}</span>
|
e.stopPropagation();
|
||||||
{s.folder && <span className="sound-duration">{s.folder}</span>}
|
setCtxMenu({
|
||||||
<div className="playing-indicator">
|
x: Math.min(e.clientX, window.innerWidth - 170),
|
||||||
<div className="wave-bar" /><div className="wave-bar" />
|
y: Math.min(e.clientY, window.innerHeight - 140),
|
||||||
<div className="wave-bar" /><div className="wave-bar" />
|
sound: s,
|
||||||
</div>
|
});
|
||||||
</div>
|
}}
|
||||||
);
|
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
|
||||||
})}
|
>
|
||||||
</div>
|
{isNew && <span className="new-badge">NEU</span>}
|
||||||
)}
|
<span
|
||||||
</main>
|
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 ═══ */}
|
{/* ═══ CONTEXT MENU ═══ */}
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
|
|
@ -1658,7 +1667,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
|
||||||
{dropPhase === 'naming' && (
|
{dropPhase === 'naming' && (
|
||||||
<div className="dl-modal-actions">
|
<div className="dl-modal-actions">
|
||||||
<button className="dl-modal-cancel" onClick={handleDropSkip}>
|
<button className="dl-modal-cancel" onClick={handleDropSkip}>
|
||||||
{dropFiles.length > 1 ? 'Überspringen' : 'Abbrechen'}
|
{dropFiles.length > 1 ? '\u00dcberspringen' : 'Abbrechen'}
|
||||||
</button>
|
</button>
|
||||||
<button className="dl-modal-submit" onClick={() => void handleDropConfirm()}>
|
<button className="dl-modal-submit" onClick={() => void handleDropConfirm()}>
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
|
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
2829
web/src/styles.css
2829
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