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