feat(web): complete frontend redesign — DECK theme system
Redesigned the entire frontend with a new "DECK" aesthetic: - 5 distinctive themes: Midnight, Daylight, Neon, Vapor, Matrix - Compact pill-shaped sound buttons with category color bars - Tab navigation: All Sounds / Favorites / Recently Added - Horizontal category filter chips with color coding - Fixed bottom control bar: Stop, Random, Party, Volume - Responsive layout optimized for 800+ sounds - Syne + Outfit typography pairing - Party mode with animated gradient effects - Search with clear button, "Now Playing" indicator - Admin panel as modal overlay - Subtle dot grid background pattern on dark themes Replaces the previous Apple Music-style card layout with a dense, efficient grid that scales properly for large sound libraries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1feb7b0836
commit
ba8c07f347
4 changed files with 3291 additions and 838 deletions
|
|
@ -4,10 +4,11 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>Soundboard</title>
|
<meta name="theme-color" content="#0b0b0f" />
|
||||||
<!-- No tailwind script injection to avoid collision with our custom CSS structural layout -->
|
<title>Jukebox</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
rel="stylesheet" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
1734
web/package-lock.json
generated
Normal file
1734
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
654
web/src/App.tsx
654
web/src/App.tsx
|
|
@ -1,81 +1,101 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel } from './api';
|
import {
|
||||||
|
fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume,
|
||||||
|
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
|
||||||
|
playUrl, fetchCategories, createCategory, assignCategories, clearBadges,
|
||||||
|
updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents,
|
||||||
|
getSelectedChannels, setSelectedChannel,
|
||||||
|
} from './api';
|
||||||
import type { VoiceChannelInfo, Sound, Category } from './types';
|
import type { VoiceChannelInfo, Sound, Category } from './types';
|
||||||
import { getCookie, setCookie } from './cookies';
|
import { getCookie, setCookie } from './cookies';
|
||||||
|
|
||||||
|
/* ── Category Color Palette ── */
|
||||||
|
const CAT_PALETTE = [
|
||||||
|
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
|
||||||
|
'#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16',
|
||||||
|
'#d946ef', '#0ea5e9', '#f43f5e', '#10b981',
|
||||||
|
];
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{ id: 'midnight', label: 'Midnight' },
|
||||||
|
{ id: 'daylight', label: 'Daylight' },
|
||||||
|
{ id: 'neon', label: 'Neon' },
|
||||||
|
{ id: 'vapor', label: 'Vapor' },
|
||||||
|
{ id: 'matrix', label: 'Matrix' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Tab = 'all' | 'favorites' | 'recent';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
/* ── State ── */
|
||||||
const [sounds, setSounds] = useState<Sound[]>([]);
|
const [sounds, setSounds] = useState<Sound[]>([]);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
|
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
|
||||||
const [activeFolder, setActiveFolder] = useState<string>('__all__');
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [activeCategoryId, setActiveCategoryId] = useState<string>('');
|
|
||||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
const [activeTab, setActiveTab] = useState<Tab>('all');
|
||||||
|
const [activeFolder, setActiveFolder] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
const [selected, setSelected] = useState<string>('');
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||||
const selectedRef = useRef<string>('');
|
const [selected, setSelected] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const selectedRef = useRef('');
|
||||||
const [notification, setNotification] = useState<{ msg: string, type: 'info' | 'error' } | null>(null);
|
|
||||||
|
|
||||||
const [volume, setVolume] = useState<number>(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
||||||
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
|
const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'midnight');
|
||||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
|
|
||||||
// Chaos Mode (Partymode)
|
const [chaosMode, setChaosMode] = useState(false);
|
||||||
const [chaosMode, setChaosMode] = useState<boolean>(false);
|
|
||||||
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
||||||
const chaosTimeoutRef = useRef<number | null>(null);
|
const chaosModeRef = useRef(false);
|
||||||
const chaosModeRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
// Scrolled State for Header blur
|
const [lastPlayed, setLastPlayed] = useState('');
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
||||||
|
|
||||||
|
/* ── Refs ── */
|
||||||
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
||||||
useEffect(() => { selectedRef.current = selected; }, [selected]);
|
useEffect(() => { selectedRef.current = selected; }, [selected]);
|
||||||
|
|
||||||
const showNotification = (msg: string, type: 'info' | 'error' = 'info') => {
|
/* ── Helpers ── */
|
||||||
|
const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => {
|
||||||
setNotification({ msg, type });
|
setNotification({ msg, type });
|
||||||
setTimeout(() => setNotification(null), 3000);
|
setTimeout(() => setNotification(null), 3000);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// ---------------- Init Load ----------------
|
const guildId = selected ? selected.split(':')[0] : '';
|
||||||
|
const channelId = selected ? selected.split(':')[1] : '';
|
||||||
|
|
||||||
|
/* ── Init ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [c, selectedMap] = await Promise.all([fetchChannels(), getSelectedChannels()]);
|
const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]);
|
||||||
setChannels(c);
|
setChannels(ch);
|
||||||
let initial = '';
|
if (ch.length) {
|
||||||
if (c.length > 0) {
|
const g = ch[0].guildId;
|
||||||
const firstGuild = c[0].guildId;
|
const serverCid = selMap[g];
|
||||||
const serverCid = selectedMap[firstGuild];
|
const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid);
|
||||||
if (serverCid && c.find(x => x.guildId === firstGuild && x.channelId === serverCid)) {
|
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
|
||||||
initial = `${firstGuild}:${serverCid}`;
|
|
||||||
} else {
|
|
||||||
initial = `${c[0].guildId}:${c[0].channelId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (initial) setSelected(initial);
|
|
||||||
} catch (e: any) {
|
|
||||||
showNotification(e?.message || 'Fehler beim Laden der Channels', 'error');
|
|
||||||
}
|
}
|
||||||
|
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
|
||||||
try { setIsAdmin(await adminStatus()); } catch { }
|
try { setIsAdmin(await adminStatus()); } catch { }
|
||||||
try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch { }
|
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------------- Theme ----------------
|
/* ── Theme ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.setAttribute('data-theme', theme);
|
document.body.setAttribute('data-theme', theme);
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('jb-theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
// ---------------- SSE Events ----------------
|
/* ── SSE ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribeEvents((msg) => {
|
const unsub = subscribeEvents((msg) => {
|
||||||
if (msg?.type === 'party') {
|
if (msg?.type === 'party') {
|
||||||
setPartyActiveGuilds((prev) => {
|
setPartyActiveGuilds(prev => {
|
||||||
const s = new Set(prev);
|
const s = new Set(prev);
|
||||||
if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId);
|
if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId);
|
||||||
return Array.from(s);
|
return Array.from(s);
|
||||||
|
|
@ -84,370 +104,418 @@ export default function App() {
|
||||||
setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []);
|
setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []);
|
||||||
try {
|
try {
|
||||||
const sel = msg?.selected || {};
|
const sel = msg?.selected || {};
|
||||||
const currentSelected = selectedRef.current || '';
|
const g = selectedRef.current?.split(':')[0];
|
||||||
const gid = currentSelected ? currentSelected.split(':')[0] : '';
|
if (g && sel[g]) setSelected(`${g}:${sel[g]}`);
|
||||||
if (gid && sel[gid]) {
|
|
||||||
const newVal = `${gid}:${sel[gid]}`;
|
|
||||||
setSelected(newVal);
|
|
||||||
}
|
|
||||||
} catch { }
|
} catch { }
|
||||||
try {
|
try {
|
||||||
const vols = msg?.volumes || {};
|
const vols = msg?.volumes || {};
|
||||||
const cur = selectedRef.current || '';
|
const g = selectedRef.current?.split(':')[0];
|
||||||
const gid = cur ? cur.split(':')[0] : '';
|
if (g && typeof vols[g] === 'number') setVolume(vols[g]);
|
||||||
if (gid && typeof vols[gid] === 'number') {
|
|
||||||
setVolume(vols[gid]);
|
|
||||||
}
|
|
||||||
} catch { }
|
} catch { }
|
||||||
} else if (msg?.type === 'channel') {
|
} else if (msg?.type === 'channel') {
|
||||||
try {
|
const g = selectedRef.current?.split(':')[0];
|
||||||
const gid = msg.guildId;
|
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
|
||||||
const cid = msg.channelId;
|
|
||||||
if (gid && cid) {
|
|
||||||
const currentSelected = selectedRef.current || '';
|
|
||||||
const curGid = currentSelected ? currentSelected.split(':')[0] : '';
|
|
||||||
if (curGid === gid) setSelected(`${gid}:${cid}`);
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
} else if (msg?.type === 'volume') {
|
} else if (msg?.type === 'volume') {
|
||||||
try {
|
const g = selectedRef.current?.split(':')[0];
|
||||||
const gid = msg.guildId;
|
if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume);
|
||||||
const v = msg.volume;
|
|
||||||
const cur = selectedRef.current || '';
|
|
||||||
const curGid = cur ? cur.split(':')[0] : '';
|
|
||||||
if (gid && curGid === gid && typeof v === 'number') {
|
|
||||||
setVolume(v);
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { try { unsub(); } catch { } };
|
return () => { try { unsub(); } catch { } };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gid = selected ? selected.split(':')[0] : '';
|
setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false);
|
||||||
setChaosMode(gid ? partyActiveGuilds.includes(gid) : false);
|
|
||||||
}, [selected, partyActiveGuilds]);
|
}, [selected, partyActiveGuilds]);
|
||||||
|
|
||||||
// ---------------- Data Fetching ----------------
|
/* ── Data Fetch ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
let folderParam = '__all__';
|
||||||
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, false); // Removed fuzzy param for cleaner UI
|
if (activeTab === 'recent') folderParam = '__recent__';
|
||||||
|
else if (activeFolder) folderParam = activeFolder;
|
||||||
|
|
||||||
|
const s = await fetchSounds(query, folderParam, undefined, false);
|
||||||
setSounds(s.items);
|
setSounds(s.items);
|
||||||
setTotal(s.total);
|
setTotal(s.total);
|
||||||
setFolders(s.folders);
|
setFolders(s.folders);
|
||||||
} catch (e: any) {
|
} catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); }
|
||||||
showNotification(e?.message || 'Fehler beim Laden der Sounds', 'error');
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [activeFolder, query, activeCategoryId]);
|
}, [activeTab, activeFolder, query]);
|
||||||
|
|
||||||
|
/* ── Favs persistence ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = getCookie('favs');
|
const c = getCookie('favs');
|
||||||
if (c) { try { setFavs(JSON.parse(c)); } catch { } }
|
if (c) try { setFavs(JSON.parse(c)); } catch { }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { setCookie('favs', JSON.stringify(favs)); } catch { }
|
try { setCookie('favs', JSON.stringify(favs)); } catch { }
|
||||||
}, [favs]);
|
}, [favs]);
|
||||||
|
|
||||||
|
/* ── Volume sync ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
localStorage.setItem('selectedChannel', selected);
|
(async () => {
|
||||||
try {
|
try { const v = await getVolume(guildId); setVolume(v); } catch { }
|
||||||
const [guildId] = selected.split(':');
|
|
||||||
const v = await getVolume(guildId);
|
|
||||||
setVolume(v);
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
}
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
// ---------------- Actions ----------------
|
/* ── Actions ── */
|
||||||
async function handlePlay(name: string, rel?: string) {
|
async function handlePlay(s: Sound) {
|
||||||
if (!selected) return showNotification('Bitte einen Voice-Channel auswählen', 'error');
|
if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error');
|
||||||
const [guildId, channelId] = selected.split(':');
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
await playSound(s.name, guildId, channelId, volume, s.relativePath);
|
||||||
await playSound(name, guildId, channelId, volume, rel);
|
setLastPlayed(s.name);
|
||||||
} catch (e: any) {
|
setTimeout(() => setLastPlayed(''), 4000);
|
||||||
showNotification(e?.message || 'Wiedergabe fehlgeschlagen', 'error');
|
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chaos Mode Logic
|
async function handleStop() {
|
||||||
const startChaosMode = async () => {
|
if (!selected) return;
|
||||||
if (!selected || !sounds.length) return;
|
|
||||||
const playRandomSound = async () => {
|
|
||||||
const pool = sounds;
|
|
||||||
if (!pool.length || !selected) return;
|
|
||||||
const randomSound = pool[Math.floor(Math.random() * pool.length)];
|
|
||||||
const [guildId, channelId] = selected.split(':');
|
|
||||||
try {
|
|
||||||
await playSound(randomSound.name, guildId, channelId, volume, randomSound.relativePath);
|
|
||||||
} catch (e: any) { console.error('Chaos sound play failed:', e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleNextPlay = async () => {
|
|
||||||
if (!chaosModeRef.current) return;
|
|
||||||
await playRandomSound();
|
|
||||||
const delay = 30_000 + Math.floor(Math.random() * 60_000); // 30-90 Sekunden
|
|
||||||
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
await playRandomSound();
|
|
||||||
const firstDelay = 30_000 + Math.floor(Math.random() * 60_000);
|
|
||||||
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopChaosMode = async () => {
|
|
||||||
if (chaosTimeoutRef.current) {
|
|
||||||
clearTimeout(chaosTimeoutRef.current);
|
|
||||||
chaosTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (selected) {
|
|
||||||
const [guildId] = selected.split(':');
|
|
||||||
try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
|
try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const toggleChaosMode = async () => {
|
async function handleRandom() {
|
||||||
if (chaosMode) {
|
if (!sounds.length || !selected) return;
|
||||||
setChaosMode(false);
|
const rnd = sounds[Math.floor(Math.random() * sounds.length)];
|
||||||
await stopChaosMode();
|
handlePlay(rnd);
|
||||||
if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch { } }
|
|
||||||
} else {
|
|
||||||
setChaosMode(true);
|
|
||||||
await startChaosMode();
|
|
||||||
if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch { } }
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Filter Data
|
async function toggleParty() {
|
||||||
const filtered = sounds;
|
if (chaosMode) {
|
||||||
|
await handleStop();
|
||||||
|
try { await partyStop(guildId); } catch { }
|
||||||
|
} else {
|
||||||
|
if (!selected) return notify('Bitte einen Channel auswählen', 'error');
|
||||||
|
try { await partyStart(guildId, channelId); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Computed ── */
|
||||||
|
const displaySounds = useMemo(() => {
|
||||||
|
if (activeTab === 'favorites') {
|
||||||
|
return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
|
||||||
|
}
|
||||||
|
return sounds;
|
||||||
|
}, [sounds, activeTab, favs]);
|
||||||
|
|
||||||
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
||||||
|
|
||||||
// Scroll Handler for Top Bar Blur
|
const visibleFolders = useMemo(() =>
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)),
|
||||||
setIsScrolled(e.currentTarget.scrollTop > 20);
|
[folders]);
|
||||||
};
|
|
||||||
|
|
||||||
|
const folderColorMap = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; });
|
||||||
|
return m;
|
||||||
|
}, [visibleFolders]);
|
||||||
|
|
||||||
|
/* ── Admin State ── */
|
||||||
|
const [adminPwd, setAdminPwd] = useState('');
|
||||||
|
|
||||||
|
async function handleAdminLogin() {
|
||||||
|
try {
|
||||||
|
const ok = await adminLogin(adminPwd);
|
||||||
|
if (ok) { setIsAdmin(true); setAdminPwd(''); notify('Admin eingeloggt'); }
|
||||||
|
else notify('Falsches Passwort', 'error');
|
||||||
|
} catch { notify('Login fehlgeschlagen', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminLogout() {
|
||||||
|
try { await adminLogout(); setIsAdmin(false); notify('Ausgeloggt'); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render ── */
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className={`app-shell ${chaosMode ? 'party-active' : ''}`}>
|
||||||
{/* ---------------- Sidebar ---------------- */}
|
|
||||||
<aside className="sidebar">
|
|
||||||
<h1 className="title-large" style={{ marginLeft: 12, marginBottom: 32, fontSize: 28, background: 'linear-gradient(45deg, var(--accent-blue), #5e5ce6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
|
||||||
Jukebox
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="sidebar-title">Library</div>
|
{/* ════════ Header ════════ */}
|
||||||
<button className={`nav-item ${activeFolder === '__all__' ? 'active' : ''}`} onClick={() => setActiveFolder('__all__')}>
|
<header className="header">
|
||||||
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>library_music</span>All Sounds</div>
|
<div className="logo">JUKEBOX</div>
|
||||||
<span style={{ opacity: 0.5, fontSize: 12 }}>{total}</span>
|
|
||||||
</button>
|
|
||||||
<button className={`nav-item ${activeFolder === '__favs__' ? 'active' : ''}`} onClick={() => setActiveFolder('__favs__')}>
|
|
||||||
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>favorite</span>Favorites</div>
|
|
||||||
<span style={{ opacity: 0.5, fontSize: 12 }}>{favCount}</span>
|
|
||||||
</button>
|
|
||||||
<button className={`nav-item ${activeFolder === '__recent__' ? 'active' : ''}`} onClick={() => setActiveFolder('__recent__')}>
|
|
||||||
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>schedule</span>Recently Added</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{folders.length > 3 && ( // 3 = __all__, __recent__, __top3__
|
<div className="header-search">
|
||||||
<>
|
|
||||||
<div className="sidebar-title" style={{ marginTop: 32 }}>Folders</div>
|
|
||||||
{folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)).map(f => {
|
|
||||||
const displayName = f.name.replace(/\s*\(\d+\)\s*$/, '');
|
|
||||||
return (
|
|
||||||
<button key={f.key} className={`nav-item ${activeFolder === f.key ? 'active' : ''}`} onClick={() => setActiveFolder(f.key)}>
|
|
||||||
<div className="flex items-center gap-2" style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}><span className="material-icons" style={{ fontSize: 20 }}>folder</span>{displayName}</div>
|
|
||||||
<span style={{ opacity: 0.5, fontSize: 12 }}>{f.count}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="sidebar-title" style={{ marginTop: 32 }}>Categories</div>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<button key={cat.id} className={`nav-item ${activeCategoryId === cat.id ? 'active' : ''}`} onClick={() => setActiveCategoryId(prev => prev === cat.id ? '' : cat.id)}>
|
|
||||||
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>label</span>{cat.name}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* ---------------- Main Content ---------------- */}
|
|
||||||
<main className="main-content" onScroll={handleScroll}>
|
|
||||||
<header className={`top-bar ${isScrolled ? 'scrolled' : ''}`}>
|
|
||||||
<h2 className="title-large" style={{ marginBottom: 0 }}>
|
|
||||||
{activeFolder === '__all__' ? 'All Sounds' :
|
|
||||||
activeFolder === '__favs__' ? 'Favorites' :
|
|
||||||
activeFolder === '__recent__' ? 'Recently Added' :
|
|
||||||
folders.find(f => f.key === activeFolder)?.name.replace(/\s*\(\d+\)\s*$/, '') || 'Library'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="header-actions">
|
|
||||||
<div className="search-box">
|
|
||||||
<span className="material-icons search-icon">search</span>
|
<span className="material-icons search-icon">search</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-modern"
|
placeholder="Sound suchen..."
|
||||||
placeholder="Search..."
|
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
|
<button className="search-clear" onClick={() => setQuery('')}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-meta">
|
||||||
|
<div className="sound-count">
|
||||||
|
<strong>{total}</strong> Sounds
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="select-modern"
|
className="select-clean"
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(e) => setTheme(e.target.value)}
|
onChange={e => setTheme(e.target.value)}
|
||||||
style={{ paddingRight: 20, paddingLeft: 12 }}
|
|
||||||
>
|
>
|
||||||
<option value="dark">Dark Theme</option>
|
{THEMES.map(t => (
|
||||||
<option value="light">Light Theme</option>
|
<option key={t.id} value={t.id}>{t.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`admin-toggle ${isAdmin ? 'is-admin' : ''}`}
|
||||||
|
onClick={() => setShowAdmin(true)}
|
||||||
|
title="Admin"
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 18 }}>settings</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="track-container">
|
{/* ════════ Tab Bar ════════ */}
|
||||||
<div className="track-grid">
|
<nav className="tab-bar">
|
||||||
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => {
|
|
||||||
const key = `${s.relativePath ?? s.fileName}`;
|
|
||||||
const isFav = !!favs[key];
|
|
||||||
return (
|
|
||||||
<div key={key} className="track-card" onClick={() => handlePlay(s.name, s.relativePath)}>
|
|
||||||
<button
|
<button
|
||||||
className={`fav-btn ${isFav ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'all' ? 'active' : ''}`}
|
||||||
onClick={(e) => { e.stopPropagation(); setFavs(prev => ({ ...prev, [key]: !prev[key] })); }}
|
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
|
||||||
>
|
>
|
||||||
<span className="material-icons">{isFav ? 'star' : 'star_border'}</span>
|
<span className="material-icons" style={{ fontSize: 16 }}>library_music</span>
|
||||||
|
All Sounds
|
||||||
|
<span className="tab-badge">{total}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="track-icon">
|
<button
|
||||||
<span className="material-icons" style={{ fontSize: 32 }}>music_note</span>
|
className={`tab-btn ${activeTab === 'favorites' ? 'active' : ''}`}
|
||||||
</div>
|
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
|
||||||
<div className="track-name">{s.name}</div>
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>star</span>
|
||||||
|
Favorites
|
||||||
|
{favCount > 0 && <span className="tab-badge">{favCount}</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-btn ${activeTab === 'recent' ? 'active' : ''}`}
|
||||||
|
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>schedule</span>
|
||||||
|
Recently Added
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{Array.isArray((s as any).badges) && (s as any).badges!.includes('new') && (
|
{/* ════════ Category Filter ════════ */}
|
||||||
<div className="badge-new">NEW</div>
|
{activeTab === 'all' && visibleFolders.length > 0 && (
|
||||||
)}
|
<div className="category-strip">
|
||||||
</div>
|
{visibleFolders.map(f => {
|
||||||
|
const color = folderColorMap[f.key] || '#888';
|
||||||
|
const isActive = activeFolder === f.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`cat-chip ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveFolder(isActive ? '' : f.key)}
|
||||||
|
style={isActive ? { borderColor: color, color, background: `${color}12` } : undefined}
|
||||||
|
>
|
||||||
|
<span className="cat-dot" style={{ background: color }} />
|
||||||
|
{f.name.replace(/\s*\(\d+\)\s*$/, '')}
|
||||||
|
<span style={{ opacity: 0.5, fontSize: 10, fontWeight: 700 }}>{f.count}</span>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* ---------------- Bottom Control Bar ---------------- */}
|
{/* ════════ Sound Grid ════════ */}
|
||||||
<div className="bottom-player">
|
<div className="sounds-area">
|
||||||
<div className="player-section">
|
{displaySounds.length === 0 ? (
|
||||||
{/* Target Channel */}
|
<div className="sounds-empty">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<span className="material-icons">
|
||||||
<span className="material-icons" style={{ color: 'var(--text-secondary)' }}>headset_mic</span>
|
{activeTab === 'favorites' ? 'star_border' : 'music_off'}
|
||||||
|
</span>
|
||||||
|
<p>
|
||||||
|
{activeTab === 'favorites'
|
||||||
|
? 'Noch keine Favorites — klick den Stern!'
|
||||||
|
: query
|
||||||
|
? `Kein Sound für "${query}" gefunden`
|
||||||
|
: 'Keine Sounds vorhanden'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sounds-grid">
|
||||||
|
{displaySounds.map((s, idx) => {
|
||||||
|
const key = s.relativePath ?? s.fileName;
|
||||||
|
const isFav = !!favs[key];
|
||||||
|
const color = s.folder ? folderColorMap[s.folder] || '#555' : '#555';
|
||||||
|
const isNew = s.badges?.includes('new');
|
||||||
|
const isTop = s.badges?.includes('top');
|
||||||
|
const isPlaying = lastPlayed === s.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`sound-btn ${isPlaying ? 'is-playing' : ''}`}
|
||||||
|
onClick={() => handlePlay(s)}
|
||||||
|
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
|
||||||
|
style={{ animationDelay: `${Math.min(idx * 8, 400)}ms` }}
|
||||||
|
>
|
||||||
|
<span className="cat-bar" style={{ background: color }} />
|
||||||
|
<span className="sound-label">{s.name}</span>
|
||||||
|
|
||||||
|
{isNew && <span className="badge-dot new" />}
|
||||||
|
{isTop && !isNew && <span className="badge-dot top" />}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`fav-star ${isFav ? 'is-fav' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFavs(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 14 }}>
|
||||||
|
{isFav ? 'star' : 'star_border'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ════════ Control Bar ════════ */}
|
||||||
|
<div className="control-bar">
|
||||||
|
<div className="ctrl-section left">
|
||||||
|
<div className="channel-wrap">
|
||||||
|
<span className="material-icons">headset_mic</span>
|
||||||
<select
|
<select
|
||||||
className="select-modern"
|
className="channel-select"
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={async (e) => {
|
onChange={async e => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setSelected(v);
|
setSelected(v);
|
||||||
try {
|
try {
|
||||||
const [gid, cid] = v.split(':');
|
const [g, c] = v.split(':');
|
||||||
await setSelectedChannel(gid, cid);
|
await setSelectedChannel(g, c);
|
||||||
} catch { }
|
} catch { }
|
||||||
}}
|
}}
|
||||||
style={{ width: '240px', background: 'transparent', border: '1px solid var(--border-color)' }}
|
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select Channel...</option>
|
<option value="" disabled>Channel...</option>
|
||||||
{channels.map((c) => (
|
{channels.map(c => (
|
||||||
<option key={`${c.guildId}:${c.channelId}`} value={`${c.guildId}:${c.channelId}`}>
|
<option key={`${c.guildId}:${c.channelId}`} value={`${c.guildId}:${c.channelId}`}>
|
||||||
{c.guildName} • {c.channelName}
|
{c.guildName} · {c.channelName}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lastPlayed && (
|
||||||
|
<div className="now-playing">
|
||||||
|
<span className="material-icons" style={{ fontSize: 14, verticalAlign: -2, marginRight: 4 }}>play_arrow</span>
|
||||||
|
<span>{lastPlayed}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="player-section center">
|
<div className="ctrl-section center">
|
||||||
{/* Playback Controls */}
|
<button className="ctrl-btn stop" onClick={handleStop} title="Stop">
|
||||||
<button
|
<span className="material-icons">stop</span>
|
||||||
className="btn-danger"
|
<span>Stop</span>
|
||||||
onClick={async () => {
|
</button>
|
||||||
setChaosMode(false); await stopChaosMode();
|
|
||||||
if (selected) { const [guildId] = selected.split(':'); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } }
|
<button className="ctrl-btn shuffle" onClick={handleRandom} title="Zufälliger Sound">
|
||||||
}}
|
<span className="material-icons">shuffle</span>
|
||||||
title="Stop All"
|
<span>Random</span>
|
||||||
>
|
|
||||||
<span className="material-icons" style={{ fontSize: 20 }}>stop</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className={`ctrl-btn party ${chaosMode ? 'active' : ''}`}
|
||||||
onClick={async () => {
|
onClick={toggleParty}
|
||||||
try {
|
title="Partymode"
|
||||||
const items = sounds;
|
|
||||||
if (!items.length || !selected) return;
|
|
||||||
const rnd = items[Math.floor(Math.random() * items.length)];
|
|
||||||
const [guildId, channelId] = selected.split(':');
|
|
||||||
await playSound(rnd.name, guildId, channelId, volume, rnd.relativePath);
|
|
||||||
} catch { }
|
|
||||||
}}
|
|
||||||
title="Play Random Sound"
|
|
||||||
style={{ padding: '12px 24px', display: 'flex', alignItems: 'center', gap: 6 }}
|
|
||||||
>
|
>
|
||||||
<span className="material-icons" style={{ fontSize: 20 }}>shuffle</span> Shuffle
|
<span className="material-icons">{chaosMode ? 'celebration' : 'auto_awesome'}</span>
|
||||||
</button>
|
<span>{chaosMode ? 'Party!' : 'Party'}</span>
|
||||||
|
|
||||||
<button
|
|
||||||
className={`btn-icon ${chaosMode ? 'btn-chaos' : ''}`}
|
|
||||||
onClick={toggleChaosMode}
|
|
||||||
title="Partymode (Auto-Play)"
|
|
||||||
style={chaosMode ? { width: 'auto', padding: '0 16px', borderRadius: 999, display: 'flex', gap: 6 } : {}}
|
|
||||||
>
|
|
||||||
<span className="material-icons" style={{ fontSize: 20 }}>{chaosMode ? 'celebration' : 'all_inclusive'}</span>
|
|
||||||
{chaosMode && <span>Party Active</span>}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="player-section right">
|
<div className="ctrl-section right">
|
||||||
{/* Volume */}
|
<div className="volume-wrap">
|
||||||
<div className="volume-container">
|
<span
|
||||||
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_down</span>
|
className="material-icons"
|
||||||
|
onClick={() => {
|
||||||
|
const newVol = volume > 0 ? 0 : 0.5;
|
||||||
|
setVolume(newVol);
|
||||||
|
if (guildId) setVolumeLive(guildId, newVol).catch(() => {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
className="volume-slider"
|
className="volume-slider"
|
||||||
type="range"
|
type="range"
|
||||||
min={0} max={1} step={0.01}
|
min={0} max={1} step={0.01}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={async (e) => {
|
onChange={async e => {
|
||||||
const v = parseFloat(e.target.value);
|
const v = parseFloat(e.target.value);
|
||||||
setVolume(v);
|
setVolume(v);
|
||||||
try { (e.target as HTMLInputElement).style.setProperty('--_fill', `${Math.round(v * 100)}%`); } catch { }
|
if (guildId) try { await setVolumeLive(guildId, v); } catch { }
|
||||||
if (selected) { const [guildId] = selected.split(':'); try { await setVolumeLive(guildId, v); } catch { } }
|
|
||||||
}}
|
}}
|
||||||
style={{ ['--_fill' as any]: `${Math.round(volume * 100)}%` }}
|
style={{ '--fill': `${Math.round(volume * 100)}%` } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_up</span>
|
<span className="volume-pct">{Math.round(volume * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ════════ Notification Toast ════════ */}
|
||||||
{notification && (
|
{notification && (
|
||||||
<div className={`notification ${notification.type}`}>
|
<div className={`toast ${notification.type}`}>
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>
|
<span className="material-icons" style={{ fontSize: 16 }}>
|
||||||
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
|
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
|
||||||
</span>
|
</span>
|
||||||
{notification.msg}
|
{notification.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ════════ Admin Panel Overlay ════════ */}
|
||||||
|
{showAdmin && (
|
||||||
|
<div className="admin-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAdmin(false); }}>
|
||||||
|
<div className="admin-panel">
|
||||||
|
<h3>
|
||||||
|
Admin
|
||||||
|
<button className="admin-close" onClick={() => setShowAdmin(false)}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!isAdmin ? (
|
||||||
|
<div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminPwd}
|
||||||
|
onChange={e => setAdminPwd(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
|
||||||
|
placeholder="Admin-Passwort..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="admin-btn primary" onClick={handleAdminLogin}>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
Eingeloggt als Admin
|
||||||
|
</p>
|
||||||
|
<button className="admin-btn outline" onClick={handleAdminLogout}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1688
web/src/styles.css
1688
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