From 187905d22b8ecb4b403c53950fdc58b546e5822a Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 26 Feb 2026 13:47:54 +0100 Subject: [PATCH 01/22] feat: complete apple ui redesign on stable --- .github/workflows/docker-build.yml | 8 +- web/index.html | 30 +- web/src/App.tsx | 860 ++++++------------- web/src/styles.css | 1243 +++++++++++++--------------- 4 files changed, 849 insertions(+), 1292 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 3a25324..ffdb6e8 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,12 +31,16 @@ jobs: echo "tag=main" >> $GITHUB_OUTPUT echo "version=1.1.0" >> $GITHUB_OUTPUT echo "channel=stable" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref_name }}" == "feature/nightly" ]] || [[ "${{ github.ref_name }}" == "nightly" ]]; then + echo "tag=nightly" >> $GITHUB_OUTPUT + echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT + echo "channel=nightly" >> $GITHUB_OUTPUT else # Ersetze Slashes durch Bindestriche für gültige Docker Tags CLEAN_TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g') echo "tag=$CLEAN_TAG" >> $GITHUB_OUTPUT - echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT - echo "channel=nightly" >> $GITHUB_OUTPUT + echo "version=1.1.0-dev" >> $GITHUB_OUTPUT + echo "channel=dev" >> $GITHUB_OUTPUT fi # Nur auf main: auch :latest tag pushen diff --git a/web/index.html b/web/index.html index cf00157..e3aa098 100644 --- a/web/index.html +++ b/web/index.html @@ -1,21 +1,19 @@ - - - - Soundboard - - - - - - - -
- - - - + + + + Soundboard + + + + + + +
+ + \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index 5032d73..7479572 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import ReactDOM from 'react-dom'; 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 { getCookie, setCookie } from './cookies'; @@ -13,64 +12,35 @@ export default function App() { const [activeCategoryId, setActiveCategoryId] = useState(''); const [channels, setChannels] = useState([]); const [query, setQuery] = useState(''); - const [fuzzy, setFuzzy] = useState(false); + const [selected, setSelected] = useState(''); const selectedRef = useRef(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); - const [showTop, setShowTop] = useState(false); + const [notification, setNotification] = useState<{ msg: string, type: 'info' | 'error' } | null>(null); + const [volume, setVolume] = useState(1); const [favs, setFavs] = useState>({}); const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [selectedSet, setSelectedSet] = useState>({}); - const [assignCategoryId, setAssignCategoryId] = useState(''); - const [newCategoryName, setNewCategoryName] = useState(''); - const [editingCategoryId, setEditingCategoryId] = useState(''); - const [editingCategoryName, setEditingCategoryName] = useState(''); - const [showEmojiPicker, setShowEmojiPicker] = useState(false); - const emojiPickerRef = useRef(null); - const emojiTriggerRef = useRef(null); - const [emojiPos, setEmojiPos] = useState<{left:number; top:number}>({ left: 0, top: 0 }); - const EMOJIS = useMemo(()=>{ - // einfache, breite Auswahl gängiger Emojis; kann später erweitert/extern geladen werden - const groups = [ - '😀😁😂🤣😅😊🙂😉😍😘😜🤪🤗🤔🤩🥳😎😴🤤','😇🥰🥺😡🤬😱😭🙈🙉🙊💀👻🤖🎃','👍👎👏🙌🙏🤝💪🔥✨💥🎉🎊','❤️🧡💛💚💙💜🖤🤍🤎💖💘💝','⭐🌟🌈☀️🌙⚡❄️☔🌊🍀','🎵🎶🎧🎤🎸🥁🎹🎺🎻','🍕🍔🍟🌭🌮🍣🍺🍻🍷🥂','🐶🐱🐼🐸🦄🐧🐢🦖🐙','🚀🛸✈️🚁🚗🏎️🚓🚒','🏆🥇🥈🥉🎯🎮🎲🧩'] - return groups.join('').split(''); - }, []); - function emojiToTwemojiUrl(emoji: string): string { - const codePoints = Array.from(emoji).map(ch => ch.codePointAt(0)!.toString(16)).join('-'); - // twemoji svg assets - return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoints}.svg`; - } - const [showBroccoli, setShowBroccoli] = useState(false); - const broccoliItems = useMemo(() => { - if (!(theme === '420' && showBroccoli)) return [] as Array<{top:number; left:number; duration:number; delay:number}>; - const items: Array<{top:number; left:number; duration:number; delay:number}> = []; - for (let i = 0; i < 20; i += 1) { - items.push({ - top: 5 + Math.random() * 90, // 5%..95% - left: 2 + Math.random() * 96, // 2%..98% - duration: 14 + Math.random() * 14, // 14s..28s - delay: Math.random() * 2 // 0..2s - }); - } - return items; - }, [theme, showBroccoli]); - const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); - const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]); - const [clock, setClock] = useState(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date())); - const [totalPlays, setTotalPlays] = useState(0); - const [mediaUrl, setMediaUrl] = useState(''); + // Chaos Mode (Partymode) const [chaosMode, setChaosMode] = useState(false); + const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); const chaosTimeoutRef = useRef(null); const chaosModeRef = useRef(false); + + // Scrolled State for Header blur + const [isScrolled, setIsScrolled] = useState(false); + useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); useEffect(() => { selectedRef.current = selected; }, [selected]); + const showNotification = (msg: string, type: 'info' | 'error' = 'info') => { + setNotification({ msg, type }); + setTimeout(() => setNotification(null), 3000); + }; + + // ---------------- Init Load ---------------- useEffect(() => { (async () => { try { @@ -88,18 +58,20 @@ export default function App() { } if (initial) setSelected(initial); } catch (e: any) { - setError(e?.message || 'Fehler beim Laden der Channels'); + showNotification(e?.message || 'Fehler beim Laden der Channels', 'error'); } - try { setIsAdmin(await adminStatus()); } catch {} - try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch {} - try { - const h = await fetch('/api/health').then(r => r.json()).catch(() => null); - if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); - } catch {} + try { setIsAdmin(await adminStatus()); } catch { } + try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch { } })(); }, []); - // SSE: Partymode-Status global synchronisieren (sauberes Cleanup) + // ---------------- Theme ---------------- + useEffect(() => { + document.body.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }, [theme]); + + // ---------------- SSE Events ---------------- useEffect(() => { const unsub = subscribeEvents((msg) => { if (msg?.type === 'party') { @@ -118,16 +90,15 @@ export default function App() { const newVal = `${gid}:${sel[gid]}`; setSelected(newVal); } - } catch {} + } catch { } try { const vols = msg?.volumes || {}; const cur = selectedRef.current || ''; const gid = cur ? cur.split(':')[0] : ''; if (gid && typeof vols[gid] === 'number') { - const v = vols[gid]; - setVolume(v); + setVolume(vols[gid]); } - } catch {} + } catch { } } else if (msg?.type === 'channel') { try { const gid = msg.guildId; @@ -137,7 +108,7 @@ export default function App() { const curGid = currentSelected ? currentSelected.split(':')[0] : ''; if (curGid === gid) setSelected(`${gid}:${cid}`); } - } catch {} + } catch { } } else if (msg?.type === 'volume') { try { const gid = msg.guildId; @@ -147,134 +118,71 @@ export default function App() { if (gid && curGid === gid && typeof v === 'number') { setVolume(v); } - } catch {} + } catch { } } }); - return () => { try { unsub(); } catch {} }; + return () => { try { unsub(); } catch { } }; }, []); - // Aus aktivem Guild-Status die lokale Anzeige setzen useEffect(() => { const gid = selected ? selected.split(':')[0] : ''; setChaosMode(gid ? partyActiveGuilds.includes(gid) : false); }, [selected, partyActiveGuilds]); - // Uhrzeit (Berlin) aktualisieren - useEffect(() => { - const fmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }); - const update = () => setClock(fmt.format(new Date())); - const id = setInterval(update, 1000); - update(); - return () => clearInterval(id); - }, []); - + // ---------------- Data Fetching ---------------- useEffect(() => { (async () => { try { const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder; - const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, fuzzy); + const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, false); // Removed fuzzy param for cleaner UI setSounds(s.items); setTotal(s.total); setFolders(s.folders); } catch (e: any) { - setError(e?.message || 'Fehler beim Laden der Sounds'); + showNotification(e?.message || 'Fehler beim Laden der Sounds', 'error'); } })(); - }, [activeFolder, query, activeCategoryId, fuzzy]); + }, [activeFolder, query, activeCategoryId]); - // Favoriten aus Cookie laden useEffect(() => { const c = getCookie('favs'); - if (c) { - try { setFavs(JSON.parse(c)); } catch {} - } + if (c) { try { setFavs(JSON.parse(c)); } catch { } } }, []); - // Favoriten persistieren useEffect(() => { - try { setCookie('favs', JSON.stringify(favs)); } catch {} + try { setCookie('favs', JSON.stringify(favs)); } catch { } }, [favs]); - // Theme anwenden/persistieren - useEffect(() => { - document.body.setAttribute('data-theme', theme); - if (import.meta.env.VITE_BUILD_CHANNEL === 'nightly') { - document.body.setAttribute('data-build', 'nightly'); - } else { - document.body.removeAttribute('data-build'); - } - localStorage.setItem('theme', theme); - }, [theme]); - - // Back-to-top Sichtbarkeit - useEffect(() => { - const onScroll = () => setShowTop(window.scrollY > 300); - onScroll(); - window.addEventListener('scroll', onScroll, { passive: true }); - return () => window.removeEventListener('scroll', onScroll); - }, []); - - // Live-Update für totalPlays Counter - useEffect(() => { - const updateTotalPlays = async () => { - try { - const h = await fetch('/api/health').then(r => r.json()).catch(() => null); - if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); - } catch {} - }; - - // Sofort beim Start laden - updateTotalPlays(); - - // Alle 5 Sekunden aktualisieren - const interval = setInterval(updateTotalPlays, 5000); - return () => clearInterval(interval); - }, []); - useEffect(() => { (async () => { if (selected) { localStorage.setItem('selectedChannel', selected); - // gespeicherte Lautstärke vom Server laden try { const [guildId] = selected.split(':'); const v = await getVolume(guildId); setVolume(v); - } catch {} + } catch { } } })(); }, [selected]); - // Server liefert bereits gefilterte (und ggf. fuzzy-sortierte) Ergebnisse - const filtered = sounds; - - const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); - - function toggleSelect(key: string, on?: boolean) { - setSelectedSet((prev) => ({ ...prev, [key]: typeof on === 'boolean' ? on : !prev[key] })); - } - function clearSelection() { - setSelectedSet({}); - } - + // ---------------- Actions ---------------- async function handlePlay(name: string, rel?: string) { - setError(null); - if (!selected) return setError('Bitte einen Voice-Channel auswählen'); + if (!selected) return showNotification('Bitte einen Voice-Channel auswählen', 'error'); const [guildId, channelId] = selected.split(':'); try { setLoading(true); await playSound(name, guildId, channelId, volume, rel); } catch (e: any) { - setError(e?.message || 'Play fehlgeschlagen'); + showNotification(e?.message || 'Wiedergabe fehlgeschlagen', 'error'); } finally { setLoading(false); } } - // CHAOS Mode Funktionen (zufällige Wiedergabe alle 1-3 Minuten) + // Chaos Mode Logic const startChaosMode = async () => { if (!selected || !sounds.length) return; - const playRandomSound = async () => { const pool = sounds; if (!pool.length || !selected) return; @@ -282,9 +190,7 @@ export default function App() { 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); - } + } catch (e: any) { console.error('Chaos sound play failed:', e); } }; const scheduleNextPlay = async () => { @@ -294,9 +200,7 @@ export default function App() { chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay); }; - // Sofort ersten Sound abspielen await playRandomSound(); - // Nächsten zufällig in 1-3 Minuten planen const firstDelay = 30_000 + Math.floor(Math.random() * 60_000); chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay); }; @@ -306,15 +210,9 @@ export default function App() { clearTimeout(chaosTimeoutRef.current); chaosTimeoutRef.current = null; } - - // Alle Sounds stoppen (wie Panic Button) if (selected) { const [guildId] = selected.split(':'); - try { - await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); - } catch (e: any) { - console.error('Chaos stop failed:', e); - } + try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } } }; @@ -322,502 +220,234 @@ export default function App() { if (chaosMode) { setChaosMode(false); await stopChaosMode(); - // serverseitig stoppen - if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch {} } + if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch { } } } else { setChaosMode(true); await startChaosMode(); - // serverseitig starten - if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch {} } + if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch { } } } }; - // Cleanup bei Komponenten-Unmount - useEffect(() => { - return () => { - if (chaosTimeoutRef.current) { - clearTimeout(chaosTimeoutRef.current); - } - }; - }, []); + // Filter Data + const filtered = sounds; + const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); + + // Scroll Handler for Top Bar Blur + const handleScroll = (e: React.UIEvent) => { + setIsScrolled(e.currentTarget.scrollTop > 20); + }; return ( - -
- {/* Floating Broccoli for 420 Theme */} - {theme === '420' && showBroccoli && ( +
+ {/* ---------------- Sidebar ---------------- */} + + + {/* ---------------- Main Content ---------------- */} +
+
+

+ {activeFolder === '__all__' ? 'All Sounds' : + activeFolder === '__favs__' ? 'Favorites' : + activeFolder === '__recent__' ? 'Recently Added' : + folders.find(f => f.key === activeFolder)?.name.replace(/\s*\(\d+\)\s*$/, '') || 'Library'} +

+ +
+
+ search + setQuery(e.target.value)} + />
+ +
-
-
-
- -
- setQuery(e.target.value)} /> - search -
-
-
- { +
+
+ {(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => { + const key = `${s.relativePath ?? s.fileName}`; + const isFav = !!favs[key]; + return ( +
handlePlay(s.name, s.relativePath)}> + +
+ music_note +
+
{s.name}
+ + {Array.isArray((s as any).badges) && (s as any).badges!.includes('new') && ( +
NEW
+ )} +
+ ); + })} +
+
+
+ + {/* ---------------- Bottom Control Bar ---------------- */} +
+
+ {/* Target Channel */} +
+ headset_mic + { - const v = parseFloat(e.target.value); - setVolume(v); - // CSS-Variable setzen, um die Füllbreite zu steuern - const percent = `${Math.round(v * 100)}%`; - try { (e.target as HTMLInputElement).style.setProperty('--_fill', percent); } catch {} - if(selected){ const [guildId]=selected.split(':'); try{ await setVolumeLive(guildId, v);}catch{} } - }} - // Initiale Füllbreite, falls State geladen ist - style={{ ['--_fill' as any]: `${Math.round(volume*100)}%` }} - /> - {Math.round(volume*100)}% -
-
- setMediaUrl(e.target.value)} onKeyDown={async (e)=>{ if(e.key==='Enter'){ if(!selected){ setError('Bitte Voice-Channel wählen'); setInfo(null); return;} const [guildId,channelId]=selected.split(':'); try{ await playUrl(mediaUrl,guildId,channelId,volume); setError(null); setInfo('MP3 heruntergeladen und abgespielt.'); }catch(err:any){ setInfo(null); setError(err?.message||'Download fehlgeschlagen'); } } }} /> - link - -
-
-
- - palette - unfold_more -
- {theme === '420' && ( -
- setShowBroccoli(e.target.checked)} - className="w-4 h-4 accent-green-500" - /> - -
- )} -
-
-
-
- {!isAdmin ? ( - <> -
- setAdminPwd(e.target.value)} - onKeyDown={async (e)=>{ - if(e.key === 'Enter') { - const ok = await adminLogin(adminPwd); - if(ok) { - setIsAdmin(true); - setAdminPwd(''); - } else { - alert('Login fehlgeschlagen'); - } - } - }} - /> - lock -
- - - ) : ( -
- Ausgewählt: {selectedCount} - {selectedCount > 0 && ( - - )} - {selectedCount === 1 && ( - { - const from = Object.entries(selectedSet).find(([,v])=>v)?.[0]; - if(!from) return; - try { - await adminRename(from, newName); - clearSelection(); - const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined, fuzzy); - setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders); - } catch (e:any) { setError(e?.message||'Umbenennen fehlgeschlagen'); } - }} /> - )} - {/* Kategorien-Zuweisung */} - {selectedCount > 0 && ( - <> - - - - {/* Custom Emoji Feature entfernt */} - - - - )} - -
- - {/* Kategorien: anlegen/umbenennen/löschen */} - setNewCategoryName(e.target.value)} style={{maxWidth:200}} /> - - - - setEditingCategoryName(e.target.value)} style={{maxWidth:200}} /> - - - - -
- )} -
-
-
- - {error &&
{error}
} - {info &&
{info}
} - -
-
- - {folders.map(f=> { - const displayName = f.name.replace(/\s*\(\d+\)\s*$/, ''); - return ( - - ); - })} -
- {categories.length > 0 && ( -
- {categories.map(cat => ( - + } catch { } + }} + style={{ width: '240px', background: 'transparent', border: '1px solid var(--border-color)' }} + > + + {channels.map((c) => ( + ))} -
- )} + +
-
- {(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => { - const key = `${s.relativePath ?? s.fileName}`; - const isFav = !!favs[key]; - return ( -
- {isAdmin && ( - { e.stopPropagation(); toggleSelect(key, e.target.checked); }} - /> - )} -
handlePlay(s.name, s.relativePath)}> - - {s.name} - {Array.isArray((s as any).badges) && (s as any).badges!.map((b:string, i:number)=> ( - {b==='new'?'🆕': b==='rocket'?'🚀': b} - ))} - -
- -
-
-
- ); - })} -
- {/* Footer intentionally left without version display */} +
+ {/* Playback Controls */} + + + + + +
+ +
+ {/* Volume */} +
+ volume_down + { + const v = parseFloat(e.target.value); + setVolume(v); + try { (e.target as HTMLInputElement).style.setProperty('--_fill', `${Math.round(v * 100)}%`); } catch { } + if (selected) { const [guildId] = selected.split(':'); try { await setVolumeLive(guildId, v); } catch { } } + }} + style={{ ['--_fill' as any]: `${Math.round(volume * 100)}%` }} + /> + volume_up +
+
- {showTop && ( - - )} - - ); -} -type SelectProps = { - channels: VoiceChannelInfo[]; - value: string; - onChange: (v: string) => void; -}; - -function CustomSelect({ channels, value, onChange }: SelectProps) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const triggerRef = useRef(null); - const [menuPos, setMenuPos] = useState<{ left: number; top: number; width: number }>({ left: 0, top: 0, width: 0 }); - useEffect(() => { - const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; - window.addEventListener('click', close); - return () => window.removeEventListener('click', close); - }, []); - - useEffect(() => { - if (!open) return; - const update = () => { - const el = triggerRef.current; - if (!el) return; - const r = el.getBoundingClientRect(); - setMenuPos({ left: Math.round(r.left), top: Math.round(r.bottom + 6), width: Math.round(r.width) }); - }; - update(); - window.addEventListener('resize', update); - window.addEventListener('scroll', update, true); - return () => { - window.removeEventListener('resize', update); - window.removeEventListener('scroll', update, true); - }; - }, [open]); - - const current = channels.find(c => `${c.guildId}:${c.channelId}` === value); - - return ( -
- - {open && typeof document !== 'undefined' && ReactDOM.createPortal( -
- {channels.map((c) => { - const v = `${c.guildId}:${c.channelId}`; - const active = v === value; - return ( - - ); - })} -
, - document.body - )} -
- ); -} - -// Einfache ErrorBoundary, damit die Seite nicht blank wird und Fehler sichtbar sind -class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error?: Error }>{ - constructor(props: { children: React.ReactNode }) { - super(props); - this.state = { error: undefined }; - } - static getDerivedStateFromError(error: Error) { return { error }; } - componentDidCatch(error: Error, info: any) { console.error('UI-ErrorBoundary:', error, info); } - render() { - if (this.state.error) { - return ( -
-

Es ist ein Fehler aufgetreten

-
{String(this.state.error.message || this.state.error)}
- + {notification && ( +
+ + {notification.type === 'error' ? 'error_outline' : 'check_circle'} + + {notification.msg}
- ); - } - return this.props.children as any; - } -} + )} -// Inline-Komponente für Umbenennen (nur bei genau 1 Selektion sichtbar) -type RenameInlineProps = { onSubmit: (newName: string) => void | Promise }; -function RenameInline({ onSubmit }: RenameInlineProps) { - const [val, setVal] = useState(''); - async function submit() { - const n = val.trim(); - if (!n) return; - await onSubmit(n); - setVal(''); - } - return ( -
- setVal(e.target.value)} - placeholder="Neuer Name" - onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }} - style={{ color: '#000000' }} - /> -
); } - - - - - diff --git a/web/src/styles.css b/web/src/styles.css index e904884..dd353d0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,725 +1,650 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); -:root { color-scheme: dark; --range-track-h: 8px; --range-thumb-d: 20px; } -* { box-sizing: border-box; } -[data-theme="dark"] body, -body { +:root { + color-scheme: light dark; + + /* Apple Light Theme Variables */ + --bg-color: #f5f5f7; + --bg-sidebar: rgba(245, 245, 247, 0.6); + --bg-player: rgba(255, 255, 255, 0.7); + --bg-card: #ffffff; + --bg-card-hover: #f0f0f2; + --bg-input: rgba(0, 0, 0, 0.05); + + --text-primary: #1d1d1f; + --text-secondary: #86868b; + --text-inverse: #ffffff; + + --border-color: rgba(0, 0, 0, 0.1); + --border-card: rgba(0, 0, 0, 0.05); + + --accent-blue: #0071e3; + --accent-red: #ff3b30; + --accent-green: #34c759; + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08); + + /* Dimensions */ + --sidebar-w: 260px; + --player-h: 80px; +} + +[data-theme="dark"] { + --bg-color: #000000; + --bg-sidebar: rgba(28, 28, 30, 0.5); + --bg-player: rgba(28, 28, 30, 0.65); + --bg-card: rgba(44, 44, 46, 0.7); + --bg-card-hover: rgba(58, 58, 60, 0.9); + --bg-input: rgba(255, 255, 255, 0.1); + + --text-primary: #f5f5f7; + --text-secondary: #86868b; + --text-inverse: #ffffff; + + --border-color: rgba(255, 255, 255, 0.1); + --border-card: rgba(255, 255, 255, 0.05); + + --accent-blue: #0a84ff; + --accent-red: #ff453a; + --accent-green: #32d74b; + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +* { + box-sizing: border-box; margin: 0; - font-family: ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; - background: - radial-gradient(1200px 800px at 15% -10%, rgba(99,102,241,.25), transparent 60%), - radial-gradient(1200px 800px at 110% 10%, rgba(168,85,247,.22), transparent 60%), - linear-gradient(180deg, #0b1020 0%, #0f1530 100%); - min-height: 100vh; - color: #e7e7ee; + padding: 0; } - -.gradient-text { background: -webkit-linear-gradient(45deg, #333, #555); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } - -/* Platz für linke Icons in allen Themes erzwingen */ -.input-field.pl-10 { padding-left: 2.5rem !important; } - -/* Rainbow Chaos Theme */ -[data-theme="rainbow"] body { - background: - radial-gradient(1200px 800px at 0% 0%, rgba(255,99,132,.35), transparent 60%), - radial-gradient(1200px 800px at 100% 0%, rgba(54,162,235,.35), transparent 60%), - radial-gradient(1200px 800px at 0% 100%, rgba(255,206,86,.35), transparent 60%), - radial-gradient(1200px 800px at 100% 100%, rgba(75,192,192,.35), transparent 60%), - linear-gradient(180deg, #101018 0%, #121226 100%); +body { + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + /* Prevent body scroll, layout handles it */ + height: 100vh; + width: 100vw; } -[data-theme="rainbow"] .controls.glass, -[data-theme="rainbow"] .tabs.glass, -[data-theme="rainbow"] .select-trigger, -[data-theme="rainbow"] .control input, -[data-theme="rainbow"] .control select, -[data-theme="rainbow"] .sound { - /* Abgerundete Rainbow-Rahmen wie Buttons */ - border-radius: 14px; - border: 1px solid transparent; - background: - linear-gradient(135deg, rgba(255,255,255,.16), rgba(255,255,255,.08)) padding-box, - linear-gradient(90deg, #ff6384, #36a2eb, #ffce56, #4bc0c0, #9966ff) border-box; - background-clip: padding-box, border-box; -} -[data-theme="rainbow"] .sound, -[data-theme="rainbow"] .select-trigger, -[data-theme="rainbow"] .control input, -[data-theme="rainbow"] .control select { border-radius: 14px; } -[data-theme="rainbow"] .tab { border-radius: 999px; } -[data-theme="rainbow"] .tab.active { background: linear-gradient(90deg, #ff6384AA, #36a2ebAA, #ffce56AA, #4bc0c0AA, #9966ffAA); } -[data-theme="rainbow"] .tabs.glass { border: none; background: transparent; box-shadow: none; } -/* Rainbow Chaos (Stitch) */ -@keyframes rainbow-bg { 0%{background-position:0% 50%} 50%{background-position:100% 50%} 100%{background-position:0% 50%} } -[data-theme="rainbow"] body { background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); background-size: 400% 400%; animation: rainbow-bg 15s ease infinite; } -[data-theme="rainbow"] .control-panel { background-color: rgba(30,30,30,.75); border: 1px solid #3a3a3c; backdrop-filter: blur(10px); } -[data-theme="rainbow"] .tag-btn { padding: 8px 16px; border-radius: 9999px; font-size: .875rem; font-weight: 500; background: rgba(44,44,44,.8); color: #a0a0a0; border:1px solid transparent; transition: transform .3s; cursor: pointer; text-shadow: 0 1px 2px rgba(0,0,0,.5) } -[data-theme="rainbow"] .tag-btn:hover { background: rgba(58,58,58,.9); color: #fff; transform: scale(1.1); } -[data-theme="rainbow"] .tag-btn.active { background: linear-gradient(45deg,#ff00ff,#00ffff); color: #fff; font-weight:700; border:1px solid #fff; box-shadow: 0 0 15px rgba(255,0,255,.7), 0 0 15px rgba(0,255,255,.7); } -[data-theme="rainbow"] .input-field { width:100%; background: rgba(44,44,44,.8); border:1px solid #3a3a3c; border-radius:.5rem; padding:.5rem 1rem; color:#e0e0e0; outline:none; } -[data-theme="rainbow"] .input-field:focus { box-shadow: 0 0 10px #23a6d5, 0 0 5px #e73c7e; border-color:#fff; } -[data-theme="rainbow"] .sound-btn { background: rgba(30,30,30,.75); border:1px solid #3a3a3c; box-shadow: 0 1px 2px rgba(0,0,0,.2); backdrop-filter: blur(10px); transition: transform .2s ease, box-shadow .2s; } -[data-theme="rainbow"] .sound-btn:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 8px 30px rgba(0,0,0,.5); border-color:#fff; } -[data-theme="rainbow"] .gradient-text { background: -webkit-linear-gradient(45deg,#ff8a00,#e52e71,#9c27b0); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; text-shadow: 0 0 10px rgba(255,255,255,.2) } +/* ---------------- Layout & Structure ---------------- */ -/* 420 Theme - Cannabis/Trippy */ -[data-theme="420"] body { - background: - radial-gradient(1200px 800px at 20% -20%, rgba(34,197,94,.4), transparent 60%), - radial-gradient(1200px 800px at 80% 20%, rgba(74,222,128,.3), transparent 60%), - radial-gradient(1200px 800px at 40% 80%, rgba(22,163,74,.3), transparent 60%), - linear-gradient(135deg, #22c55e, #16a34a, #15803d); - background-size: 400% 400%; - animation: cannabis-bg 20s ease infinite; - color: #f0fdf4; - font-family: 'Poppins', ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial; +.app-layout { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; position: relative; - overflow-x: hidden; } -/* Floating Broccoli Animation */ -[data-theme="420"]::before { - content: ''; - position: fixed; +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + backdrop-filter: blur(25px) saturate(200%); + -webkit-backdrop-filter: blur(25px) saturate(200%); + display: flex; + flex-direction: column; + z-index: 10; + padding: 32px 16px; + overflow-y: auto; +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; + height: 100vh; + padding-bottom: var(--player-h); + /* Space for bottom player */ + overflow: hidden; + position: relative; +} + +/* ---------------- Typography ---------------- */ + +h1, +h2, +h3, +h4 { + font-weight: 700; + letter-spacing: -0.5px; +} + +.title-large { + font-size: 34px; + font-weight: 800; + letter-spacing: -1px; + margin-bottom: 24px; +} + +.sidebar-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + color: var(--text-secondary); + margin: 24px 0 8px 12px; +} + +/* ---------------- Sidebar Items ---------------- */ + +.nav-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + transition: background 0.2s ease; + margin-bottom: 2px; + border: none; + background: transparent; + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: var(--bg-card); +} + +.nav-item.active { + background: var(--bg-input); + color: var(--accent-blue); + font-weight: 600; +} + +/* ---------------- Header Area ---------------- */ + +.top-bar { + padding: 24px 40px; + display: flex; + justify-content: space-between; + align-items: flex-end; + border-bottom: 1px solid transparent; + backdrop-filter: blur(20px); + position: sticky; top: 0; + z-index: 5; + transition: all 0.3s ease; +} + +.top-bar.scrolled { + background: var(--bg-player); + border-bottom: 1px solid var(--border-color); + padding: 16px 40px; +} + +.top-bar .title-large { + margin: 0; + transition: font-size 0.3s ease; +} + +.top-bar.scrolled .title-large { + font-size: 24px; +} + +.header-actions { + display: flex; + gap: 12px; + align-items: center; +} + +/* ---------------- Search & Inputs ---------------- */ + +.search-box { + position: relative; + width: 280px; +} + +.input-modern { + width: 100%; + background: var(--bg-input); + border: 1px solid transparent; + border-radius: 12px; + padding: 12px 16px 12px 36px; + font-size: 14px; + color: var(--text-primary); + transition: all 0.2s ease; + font-family: inherit; +} + +.input-modern:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.2); + background: var(--bg-card); +} + +[data-theme="dark"] .input-modern:focus { + box-shadow: 0 0 0 4px rgba(10, 132, 255, 0.3); +} + +.search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + font-size: 18px; +} + +/* ---------------- Track Grid ---------------- */ + +.track-container { + padding: 0 40px 40px 40px; + overflow-y: auto; + flex: 1; +} + +.track-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; +} + +.track-card { + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + box-shadow: var(--shadow-sm); + position: relative; + aspect-ratio: 1 / 1; +} + +.track-card:hover { + transform: scale(1.03) translateY(-4px); + box-shadow: var(--shadow-md); + background: var(--bg-card-hover); +} + +.track-icon { + width: 64px; + height: 64px; + background: linear-gradient(135deg, var(--bg-input), var(--border-color)); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + font-size: 28px; + color: var(--text-secondary); + transition: all 0.3s ease; +} + +.track-card:hover .track-icon { + background: var(--accent-blue); + color: white; + transform: scale(1.1); + box-shadow: 0 8px 16px rgba(0, 113, 227, 0.4); +} + +.track-name { + font-weight: 600; + font-size: 15px; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +} + +.fav-btn { + position: absolute; + top: 12px; + right: 12px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + opacity: 0; + transition: all 0.2s ease; + font-size: 20px; +} + +.track-card:hover .fav-btn, +.fav-btn.active { + opacity: 1; +} + +.fav-btn.active { + color: #ff9f0a; + /* Apple Orange */ +} + +/* ---------------- Bottom Control Bar ---------------- */ + +.bottom-player { + position: absolute; + bottom: 0; left: 0; width: 100%; - height: 100%; - pointer-events: none; - z-index: 1; + height: var(--player-h); + background: var(--bg-player); + border-top: 1px solid var(--border-color); + backdrop-filter: blur(30px) saturate(200%); + -webkit-backdrop-filter: blur(30px) saturate(200%); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + z-index: 20; } -/* Broccoli Elements */ -[data-theme="420"] .broccoli { - position: fixed; - font-size: 2rem; - opacity: 0.6; - z-index: 1; - pointer-events: none; - animation: float-broccoli 15s linear infinite; +.player-section { + display: flex; + align-items: center; + gap: 16px; + flex: 1; } -[data-theme="420"] .broccoli:nth-child(1) { - top: 10%; - left: 10%; - animation-delay: 0s; - animation-duration: 18s; +.player-section.center { + justify-content: center; + flex: 2; } -[data-theme="420"] .broccoli:nth-child(2) { - top: 20%; - right: 15%; - animation-delay: 0s; - animation-duration: 22s; +.player-section.right { + justify-content: flex-end; } -[data-theme="420"] .broccoli:nth-child(3) { - bottom: 25%; - left: 20%; - animation-delay: 0s; - animation-duration: 20s; -} +/* ---------------- Buttons & Controls ---------------- */ -[data-theme="420"] .broccoli:nth-child(4) { - bottom: 15%; - right: 10%; - animation-delay: 0s; - animation-duration: 25s; -} - -[data-theme="420"] .broccoli:nth-child(5) { - top: 50%; - left: 5%; - animation-delay: 0s; - animation-duration: 16s; -} - -[data-theme="420"] .broccoli:nth-child(6) { - top: 30%; - right: 5%; - animation-delay: 0s; - animation-duration: 19s; -} - -/* Broccoli Bounce Animation */ -@keyframes float-broccoli { - 0% { - transform: translate(0, 0) rotate(0deg); - } - 25% { - transform: translate(100px, -50px) rotate(90deg); - } - 50% { - transform: translate(200px, 0px) rotate(180deg); - } - 75% { - transform: translate(100px, 50px) rotate(270deg); - } - 100% { - transform: translate(0, 0) rotate(360deg); - } -} - -/* Ensure content stays above broccoli */ -[data-theme="420"] .container { - position: relative; - z-index: 10; -} - -@keyframes cannabis-bg { - 0% { background-position: 0% 50% } - 50% { background-position: 100% 50% } - 100% { background-position: 0% 50% } -} - -[data-theme="420"] .control-panel { - background-color: rgba(17, 24, 39, 0.6); - border: 1px solid #4ade80; - backdrop-filter: blur(10px); -} - -[data-theme="420"] .tag-btn { - padding: 8px 16px; - border-radius: 9999px; - font-size: .875rem; - font-weight: 500; - background: rgba(17, 24, 39, 0.7); - color: #d1fae5; - border: 1px solid #4ade80; - transition: all .3s ease; +.btn-icon { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; cursor: pointer; + transition: all 0.2s; } -[data-theme="420"] .tag-btn:hover { - background: rgba(34, 197, 94, 0.2); - color: #f0fdf4; +.btn-icon:hover { + background: var(--bg-input); +} + +.btn-icon.active { + color: var(--accent-blue); +} + +.btn-primary { + background: var(--accent-blue); + color: white; + border: none; + padding: 10px 20px; + border-radius: 999px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(0, 113, 227, 0.3); +} + +.btn-primary:hover { + transform: scale(1.05); + filter: brightness(1.1); +} + +.btn-danger { + background: rgba(255, 59, 48, 0.1); + color: var(--accent-red); + border: none; + padding: 10px 20px; + border-radius: 999px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-danger:hover { + background: var(--accent-red); + color: white; + box-shadow: 0 4px 12px rgba(255, 59, 48, 0.3); +} + +/* Partymode Chaos Animation Style */ +.btn-chaos { + background: linear-gradient(45deg, #ff2a6d, #d1f7ff, #05d9e8, #01012b); + background-size: 300% 300%; + color: white; + border: none; + padding: 10px 20px; + border-radius: 999px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + animation: bgPulse 3s ease infinite; + box-shadow: 0 4px 15px rgba(5, 217, 232, 0.5); + transition: transform 0.2s; +} + +.btn-chaos:hover { transform: scale(1.05); } -[data-theme="420"] .tag-btn.active { - background: #22c55e; - color: white; - font-weight: 600; - border-color: #22c55e; - box-shadow: 0 0 15px rgba(34, 197, 94, 0.5); +@keyframes bgPulse { + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } } -[data-theme="420"] .input-field { - width: 100%; - background: rgba(30, 41, 59, 0.7); - border: 2px solid transparent; - border-radius: .5rem; - padding: .5rem 1rem; - color: #d1fae5; +/* ---------------- Select & Volume ---------------- */ + +.select-modern { + appearance: none; + background: var(--bg-input); + border: 1px solid transparent; + padding: 8px 36px 8px 16px; + border-radius: 999px; + color: var(--text-primary); + font-weight: 500; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.select-modern:hover { + background: var(--bg-card); + border-color: var(--border-color); +} + +.select-modern:focus { outline: none; - transition: all .3s ease; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.2); } -[data-theme="420"] .input-field:focus { - border-color: #4ade80; - box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.25); +.volume-container { + display: flex; + align-items: center; + gap: 12px; + width: 140px; } -[data-theme="420"] .sound-btn { - background: rgba(17, 24, 39, 0.7); - border: 1px solid #4ade80; - box-shadow: 0 1px 2px rgba(0,0,0,.2); - backdrop-filter: blur(10px); - transition: all .3s ease; - color: #d1fae5; +.volume-slider { + appearance: none; + width: 100%; + height: 4px; + background: var(--border-color); + border-radius: 2px; + outline: none; + background-image: linear-gradient(var(--text-secondary), var(--text-secondary)); + background-size: var(--_fill, 0%) 100%; + background-repeat: no-repeat; } -[data-theme="420"] .sound-btn:hover { - background: #22c55e; - color: white; - transform: translateY(-2px) scale(1.02); - box-shadow: 0 8px 25px rgba(34, 197, 94, 0.4); -} - -[data-theme="420"] .gradient-text { - background: -webkit-linear-gradient(45deg, #22c55e, #4ade80, #16a34a); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0 0 10px rgba(34, 197, 94, 0.3); -} - -/* 420 Theme Header */ -[data-theme="420"] header { - background: rgba(17, 24, 39, 0.6); - border: 1px solid #4ade80; - backdrop-filter: blur(15px); - color: #f0fdf4; -} - -/* 420 Theme Volume Slider */ -[data-theme="420"] .volume-slider { - --range-accent: #22c55e; - --range-track-bg: rgba(30, 41, 59, 0.7); - accent-color: #22c55e; - border-radius: 5px; - height: 8px; - border: 1px solid #4ade80; -} - -[data-theme="420"] .volume-slider::-webkit-slider-thumb { - background: linear-gradient(45deg, #22c55e, #4ade80); - box-shadow: 0 0 10px rgba(34, 197, 94, 0.5); -} - -[data-theme="420"] .volume-slider::-moz-range-thumb { - background: linear-gradient(45deg, #22c55e, #4ade80); - box-shadow: 0 0 10px rgba(34, 197, 94, 0.5); -} - -/* 420 Theme Checkbox */ -[data-theme="420"] input[type="checkbox"] { - accent-color: #22c55e; +.volume-slider::-webkit-slider-thumb { + appearance: none; width: 16px; height: 16px; - border-radius: 3px; - border: 2px solid #4ade80; - background: rgba(17, 24, 39, 0.7); - cursor: pointer; -} - -[data-theme="420"] input[type="checkbox"]:checked { - background: #22c55e; - border-color: #22c55e; - box-shadow: 0 0 10px rgba(34, 197, 94, 0.3); -} - -/* Dark (Stitch) */ -[data-theme="dark"] .control-panel { background-color:#1e1e1e; border:1px solid #3a3a3c } -[data-theme="dark"] .tag-btn { padding:8px 16px; border-radius:9999px; font-size:.875rem; font-weight:500; background:#2c2c2c; color:#a0a0a0; border:1px solid transparent; } -[data-theme="dark"] .tag-btn:hover { background:#3a3a3a; color:#e0e0e0 } -[data-theme="dark"] .tag-btn.active { background:#0a84ff; color:#fff; font-weight:600 } -[data-theme="dark"] .input-field { width:100%; background:#2c2c2c; border:1px solid #3a3a3c; border-radius:.5rem; padding:.5rem 1rem; color:#e0e0e0 } -[data-theme="dark"] .sound-btn { background:#1e1e1e; border:1px solid #3a3a3c; box-shadow:0 1px 2px rgba(0,0,0,.2) } -[data-theme="dark"] .sound-btn:hover { transform: translateY(-1px); box-shadow:0 4px 12px rgba(0,0,0,.4); border-color:#0a84ff } -[data-theme="dark"] .gradient-text { background: -webkit-linear-gradient(45deg,#e0e0e0,#a0a0a0); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent } - -.container { width: 90vw; max-width: none; margin: 0 auto; padding: 28px; } -/* Eigener Container ohne Tailwind-Kollision */ -.page-container { width: 90vw; max-width: none; margin: 0 auto; padding: 28px; } - -/* Nightly Build: volle Breite (mind. 90% der Anzeige), kein max-width-Limit */ -[data-build="nightly"] .container { - width: 90vw; - max-width: none; -} - -/* Partymode (ehem. CHAOS) Button Regenbogen-Animation */ -.chaos-rainbow { - background: linear-gradient(45deg, #ff0000, #ff8000, #ffff00, #80ff00, #00ff00, #00ff80, #00ffff, #0080ff, #0000ff, #8000ff, #ff00ff, #ff0080); - background-size: 400% 400%; - animation: chaos-rainbow-animation 2s ease-in-out infinite; -} - -@keyframes chaos-rainbow-animation { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -/* Rainbow-Flash entfernt */ - -/* Neuer Header-Style basierend auf Google Stitch Design */ -header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24px; - margin-bottom: 18px; - background: rgba(255, 255, 255, 0.05); - border-radius: 16px; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); -} - - - -/* Rainbow Theme Header */ -[data-theme="rainbow"] header { - background: rgba(30, 30, 30, 0.4); - border: 1px solid transparent; - backdrop-filter: blur(20px); - background: - linear-gradient(135deg, rgba(255,255,255,.1), rgba(255,255,255,.05)) padding-box, - linear-gradient(90deg, #ff6384, #36a2eb, #ffce56, #4bc0c0, #9966ff) border-box; - background-clip: padding-box, border-box; - color: #ffffff; -} -/* Rainbow Header: Sekundärtexte sichtbar machen */ -[data-theme="rainbow"] header .text-gray-400 { color: #ffffff !important; opacity: 1 !important; } -/* Header-Titel und Uhrzeit */ -header h1 { - margin: 0; - font-weight: 800; - letter-spacing: .3px; - color: inherit; -} - -/* Titel-Effekte pro Theme */ -[data-theme="rainbow"] .site-title { - background: linear-gradient(45deg, #ff0000, #ff8000, #ffff00, #80ff00, #00ff00, #00ff80, #00ffff, #0080ff, #0000ff, #8000ff, #ff00ff, #ff0080); - background-clip: text; - -webkit-background-clip: text; - color: transparent; - background-size: 400% 400%; - animation: chaos-rainbow-animation 2s ease-in-out infinite; -} - -[data-theme="420"] .site-title { - color: #22C55E; -} - -/* Nightly Badge Farbe (Fallback) */ -.nightly-badge { color: #ff4d4f; } - -header p { - margin: 0; - opacity: .9; - color: inherit; -} -.clock { - font-size: 48px; - font-weight: 800; - letter-spacing: 1px; - line-height: 1; -} -.badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 8px 12px; /* wie .tab */ - border-radius: 999px; - border: 1px solid rgba(255,255,255,.25); - background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.06)); - color: #e7e7ee; - font-size: .875rem; /* 14px */ - font-weight: 600; - line-height: 1; -} - -.controls { display: grid; grid-template-columns: 1fr minmax(240px, 320px) 260px 200px; gap: 12px; align-items: center; margin-bottom: 12px; } -.controls.row1 { z-index: 5000; } -.controls.row2 { grid-template-columns: minmax(400px, 1fr); z-index: 3000; } -.controls.row3 { grid-template-columns: auto auto; justify-content: flex-start; z-index: 2000; gap: 8px; } -.controls.glass { padding: 18px; position: relative; z-index: inherit; overflow: visible; } -.controls.glass { - backdrop-filter: saturate(140%) blur(20px); - background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06)); - border: 1px solid rgba(255,255,255,.28); - border-right-color: rgba(255,255,255,.18); - border-bottom-color: rgba(255,255,255,.18); - padding: 14px; - border-radius: 18px; - box-shadow: 0 20px 40px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.25); -} -.control input, .control select { - width: 100%; - padding: 12px 14px; /* Standard ohne Icon */ - border-radius: 14px; - border: 1px solid rgba(255,255,255,.25); - background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.06)); - color: #fff; - backdrop-filter: blur(18px); - box-shadow: inset 0 1px 0 rgba(255,255,255,.2); -} -.control select option { background-color: #0f1530; color: #e7e7ee; } -.control select optgroup { background-color: #0f1530; color: #c8c8d8; } - -/* Custom Select */ -.custom-select { position: relative; z-index: 10000; } -.select-trigger { - width: 100%; - text-align: left; - padding: 12px 14px; /* Standard ohne Icon */ - border-radius: 14px; - border: 1px solid rgba(255,255,255,.32); - background: linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,.12)); - color: #e7e7ee; - backdrop-filter: blur(18px); - box-shadow: inset 0 1px 0 rgba(255,255,255,.2); -} -.select-trigger .chev { float: right; opacity: .8; } -.select-menu { - position: absolute; inset: auto 0 auto 0; top: calc(100% + 6px); - border-radius: 12px; - overflow: hidden; - border: 1px solid rgba(255,255,255,.28); - background: #0f1530; - box-shadow: 0 24px 48px rgba(0,0,0,.5); - max-height: 280px; overflow-y: auto; - z-index: 20000; -} -/* Emoji-Picker entfernt */ -.emoji-picker button:hover { filter: brightness(1.2); } -.select-item { - width: 100%; text-align: left; padding: 10px 12px; color: #e7e7ee; - background: transparent; border: 0; -} -.select-item:hover { background: rgba(255,255,255,.08); color: #fff; } -.select-item.active { background: rgba(255,255,255,.14); color: #fff; } - -/* Theme Select */ -.control.theme select { - padding: 12px 14px; - border-radius: 14px; - border: 1px solid rgba(255,255,255,.32); - background: linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,.12)); - color: #e7e7ee; - backdrop-filter: blur(18px); - box-shadow: inset 0 1px 0 rgba(255,255,255,.2); -} - -/* Konkrete Felder mit Icons: zusätzliche linke Padding über Klasse steuern */ -.with-left-icon { padding-left: 40px !important; } -.control.theme select option { background: #0f1530; color: #e7e7ee; } -.control input::placeholder { color: #c8c8d8; } - -.control.volume { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; } -.control.volume label { font-weight: 700; opacity: .9; } - -/* Volume Slider - Base Styles */ -.control.volume input[type="range"], -.volume-slider { - -webkit-appearance: none; - appearance: none; - width: 100%; - height: var(--range-track-h, 8px); - border-radius: 5px; - outline: none; - cursor: pointer; - /* Sichtbarer Füllbalken über ein Hintergrund-Gradient, Breite via --_fill gesteuert */ - background: linear-gradient(var(--range-accent), var(--range-accent)) 0/var(--_fill, 0%) 100% no-repeat, var(--range-track-bg, #2c2c2c); - vertical-align: middle; -} - -/* Tracks transparent halten, damit der Hintergrund-Gradient sichtbar ist */ -.control.volume input[type="range"]::-webkit-slider-runnable-track, -.volume-slider::-webkit-slider-runnable-track { height: 8px; background: transparent; border-radius: 5px; } -.control.volume input[type="range"]::-moz-range-track, -.volume-slider::-moz-range-track { height: 8px; background: transparent; border-radius: 5px; } -.control.volume input[type="range"]::-moz-range-progress, -.volume-slider::-moz-range-progress { background: transparent; } - -.control.volume input[type="range"]::-webkit-slider-thumb, -.volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: var(--range-thumb-d, 20px); - height: var(--range-thumb-d, 20px); - margin-top: calc((var(--range-track-h, 8px) - var(--range-thumb-d, 20px)) / 2); border-radius: 50%; + background: var(--text-primary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); cursor: pointer; - border: none; - box-shadow: 0 2px 6px rgba(0,0,0,0.3); } -.control.volume input[type="range"]::-moz-range-thumb, -.volume-slider::-moz-range-thumb { +/* ---------------- Admin Badges & Checkboxes ---------------- */ + +.admin-checkbox { + position: absolute; + top: 12px; + left: 12px; + appearance: none; width: 20px; height: 20px; - border-radius: 50%; + border: 2px solid var(--border-color); + border-radius: 6px; + background: var(--bg-card); cursor: pointer; - border: none; - box-shadow: 0 2px 6px rgba(0,0,0,0.3); + transition: all 0.2s; + z-index: 2; } -/* Volume Slider - Dark Theme */ -[data-theme="dark"] .control.volume input[type="range"], -[data-theme="dark"] .volume-slider { - --range-accent: #0a84ff; - --range-track-bg: #2c2c2c; - accent-color: #0a84ff; - border-radius: 5px; - height: var(--range-track-h, 8px); +.admin-checkbox:checked { + background: var(--accent-blue) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E") no-repeat center center; + background-size: 14px; + border-color: var(--accent-blue); } -[data-theme="dark"] .control.volume input[type="range"]::-webkit-slider-thumb, -[data-theme="dark"] .volume-slider::-webkit-slider-thumb { - background: #0a84ff; -} - -[data-theme="dark"] .control.volume input[type="range"]::-moz-range-thumb, -[data-theme="dark"] .volume-slider::-moz-range-thumb { - background: #0a84ff; -} - - - -/* Volume Slider - Rainbow Theme */ -[data-theme="rainbow"] .control.volume input[type="range"], -[data-theme="rainbow"] .volume-slider { - --range-accent: #23a6d5; - --range-track-bg: rgba(44,44,44,.8); - accent-color: #23a6d5; - border-radius: 5px; - height: var(--range-track-h, 8px); - border: 1px solid #3a3a3c; -} - -[data-theme="rainbow"] .control.volume input[type="range"]::-webkit-slider-thumb, -[data-theme="rainbow"] .volume-slider::-webkit-slider-thumb { - background: linear-gradient(45deg, #23a6d5, #e73c7e); -} - -[data-theme="rainbow"] .control.volume input[type="range"]::-moz-range-thumb, -[data-theme="rainbow"] .volume-slider::-moz-range-thumb { - background: linear-gradient(45deg, #23a6d5, #e73c7e); -} - -.error { background: rgba(255, 99, 99, .12); color: #ffd1d1; border: 1px solid rgba(255, 99, 99, .3); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; } - -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } - -/* Lineares, responsives Flow-Layout für Sounds */ -.sounds-flow { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 12px; -} -.sounds-flow .sound-wrap { - position: relative; - flex: 0 0 auto; -} -/* Kartenbreite an Tabs angelehnt */ -.sounds-flow .sound-btn { - display: inline-flex; - width: auto; - max-width: 260px; /* verhindert überlange Buttons, mehr Cards pro Zeile */ - white-space: nowrap; /* einzeilig */ - padding: 12px 16px; /* gleichmäßiges Padding links/rechts */ - justify-content: center; /* Text zentrieren */ -} -/* Soundbutton-Text minimal kräftiger als 500 */ -.sounds-flow .sound-btn > span { font-weight: 501 !important; } - -/* URL Input mit Download Button - Text soll nicht über Button laufen */ -.input-field.pl-10.with-left-icon { - padding-right: 100px !important; /* Platz für Download Button */ - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} -.sound-wrap { position: relative; display: block; } -.sound-wrap.row .sound { width: 100%; } -.row-check { width: 18px; height: 18px; accent-color: #60a5fa; } -.select-check { +.badge-new { position: absolute; - left: 8px; - top: 8px; - z-index: 5; - width: 18px; - height: 18px; - accent-color: #60a5fa; -} -.sound { - padding: 18px 16px; - border-radius: 14px; - border: 1px solid rgba(255,255,255,.18); - background: rgba(255,255,255,.08); - color: #fff; - cursor: pointer; + bottom: 0; + left: 50%; + transform: translate(-50%, 50%); + background: var(--accent-blue); + color: white; + font-size: 10px; font-weight: 700; - letter-spacing: .2px; - box-shadow: 0 10px 30px rgba(0,0,0,.25); + padding: 2px 8px; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 113, 227, 0.4); } -.sound:hover { filter: brightness(1.06); background: rgba(255,255,255,.1); } -.sound:disabled { opacity: 0.6; cursor: not-allowed; } - -.fav { +.badge-custom { position: absolute; - top: 8px; - right: 10px; - background: rgba(0,0,0,.25); - color: #fff; - border: 1px solid rgba(255,255,255,.2); - border-radius: 999px; - width: 28px; - height: 28px; - display: grid; - place-items: center; - cursor: pointer; -} -.fav.active { background: #eab308; color: #111; border-color: transparent; } - -.hint { opacity: .7; padding: 24px 0; } - -/* Footer mit Version/Build-Kanal */ -.footer-info { - opacity: .7; - font-size: 12px; - padding: 16px 0 8px; + bottom: -4px; + right: -4px; + font-size: 20px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); } -.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } -.tab { - padding: 8px 12px; - border-radius: 999px; - border: 1px solid rgba(255,255,255,.25); - background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.06)); - color: #e7e7ee; -} -.tab:hover { background: rgba(255,255,255,.12); } -.tab.active { background: linear-gradient(135deg, rgba(168,85,247,.55), rgba(59,130,246,.55)); color: #fff; border-color: transparent; } +/* ---------------- Error & Notification ---------------- */ - -/* Back to top */ -.back-to-top { +.notification { position: fixed; - right: 24px; - bottom: 24px; - padding: 10px 14px; - border-radius: 999px; - border: 1px solid rgba(255,255,255,.25); - background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.06)); - color: #e7e7ee; - box-shadow: 0 10px 30px rgba(0,0,0,.35); - z-index: 40000; -} -.back-to-top:hover { filter: brightness(1.1); } - - - - - -/* Tabs/Filter wie der Random-Button im Header (einheitlicher Stil über alle Themes) */ -[data-theme] .tag-btn { + bottom: calc(var(--player-h) + 20px); + left: 50%; + transform: translateX(-50%); padding: 12px 24px; - border-radius: 0.5rem; - background: #374151; /* bg-gray-700 */ - color: #ffffff; - font-size: .875rem; /* einheitliche Schriftgröße wie andere Themes */ - font-weight: 700; - border: none; - transition: background-color .2s ease, transform .2s ease; -} -[data-theme] .tag-btn:hover { background: #4b5563; /* bg-gray-600 */ } -[data-theme] .tag-btn.active { - background: #4b5563; - box-shadow: 0 0 0 2px rgba(255,255,255,.08) inset; + border-radius: 999px; + font-weight: 500; + font-size: 14px; + z-index: 100; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: var(--shadow-lg); + display: flex; + align-items: center; + gap: 8px; + animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } -/* Aktives Tab farbig je nach Theme */ -[data-theme="dark"] .tag-btn.active { - background: #0a84ff !important; - color: #ffffff !important; +@keyframes slideUp { + from { + transform: translate(-50%, 20px); + opacity: 0; + } + + to { + transform: translate(-50%, 0); + opacity: 1; + } } -[data-theme="rainbow"] .tag-btn.active { - background: linear-gradient(90deg, #ff6384, #36a2eb, #ffce56, #4bc0c0, #9966ff) !important; - color: #ffffff !important; - border: 1px solid transparent !important; + +.notification.error { + background: rgba(255, 59, 48, 0.9); + color: white; } -[data-theme="420"] .tag-btn.active { - background: #22c55e !important; - color: #ffffff !important; - border-color: #22c55e !important; - box-shadow: 0 0 15px rgba(34, 197, 94, 0.5); + +.notification.info { + background: rgba(52, 199, 89, 0.9); + color: white; +} + +/* Responsive */ +@media (max-width: 768px) { + .app-layout { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: 60px; + flex-direction: row; + padding: 10px; + border-right: none; + border-bottom: 1px solid var(--border-color); + overflow-x: auto; + overflow-y: hidden; + } + + .sidebar-title { + display: none; + } + + .nav-item { + white-space: nowrap; + margin-right: 8px; + margin-bottom: 0; + width: auto; + } + + .main-content { + padding-bottom: calc(var(--player-h) + 60px); + } + + .bottom-player { + flex-direction: column; + height: auto; + padding: 16px; + gap: 16px; + } + + .search-box { + width: 100%; + } } \ No newline at end of file From b7293637beb7ecca6b7e7d5d7ef4b3b28d4fa17c Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 26 Feb 2026 14:11:51 +0100 Subject: [PATCH 02/22] fix(docker): use npm install instead of prune --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 91f8191..b99af26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,8 +21,8 @@ COPY server/package*.json ./ RUN npm install --no-audit --no-fund COPY server/ . RUN npm run build -# Nur Prod-Dependencies für Runtime behalten -RUN npm prune --omit=dev +# Nur Prod-Dependencies für Runtime behalten. rm -rf and cleanly install to prevent npm prune bugs +RUN rm -rf node_modules && npm install --omit=dev --no-audit --no-fund # --- Runtime image --- FROM node:20-slim AS runtime From a5f74d350843fe8a8e5dc12cef499884ecc21531 Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 26 Feb 2026 14:32:41 +0100 Subject: [PATCH 03/22] fix(deps): update libsodium for node 20 esm resolution --- server/package-lock.json | 2384 ++++++++++++++++++++++++++++++++++++++ server/package.json | 12 +- 2 files changed, 2389 insertions(+), 7 deletions(-) create mode 100644 server/package-lock.json diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..62466b2 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2384 @@ +{ + "name": "discord-soundboard-server", + "version": "1.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord-soundboard-server", + "version": "1.1.1", + "dependencies": { + "@discordjs/opus": "^0.9.0", + "@discordjs/voice": "^0.18.0", + "cors": "^2.8.5", + "discord.js": "^14.16.3", + "express": "^4.19.2", + "libsodium-wrappers": "^0.8.2", + "multer": "^2.0.0", + "sodium-native": "^4.0.8", + "tweetnacl": "^1.0.3", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.12.12", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/builders/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/formatters/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/node-pre-gyp": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@discordjs/node-pre-gyp/-/node-pre-gyp-0.4.5.tgz", + "integrity": "sha512-YJOVVZ545x24mHzANfYoy0BJX5PDyeZlpiJjDkUBM/V/Ao7TFX9lcUvCN4nr0tbr5ubeaXxtEBILUrHtTphVeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@discordjs/opus": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@discordjs/opus/-/opus-0.9.0.tgz", + "integrity": "sha512-NEE76A96FtQ5YuoAVlOlB3ryMPrkXbUCTQICHGKb8ShtjXyubGicjRMouHtP1RpuDdm16cDa+oI3aAMo1zQRUQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@discordjs/node-pre-gyp": "^0.4.5", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@discordjs/voice": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.18.0.tgz", + "integrity": "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg==", + "license": "Apache-2.0", + "dependencies": { + "@types/ws": "^8.5.12", + "discord-api-types": "^0.37.103", + "prism-media": "^1.3.5", + "tslib": "^2.6.3", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.34", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", + "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-addon-resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", + "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", + "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==", + "license": "Apache-2.0", + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-semver": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz", + "integrity": "sha512-ESVaN2nzWhcI5tf3Zzcq9aqCZ676VWzqw07eEZ0qxAcEOAFYBa0pWq8sK34OQeHLY3JsfKXZS9mDyzyxGjeLzA==", + "license": "Apache-2.0" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", + "license": "MIT" + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/libsodium": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.2.tgz", + "integrity": "sha512-TsnGYMoZtpweT+kR+lOv5TVsnJ/9U0FZOsLFzFOMWmxqOAYXjX3fsrPAW+i1LthgDKXJnI9A8dWEanT1tnJKIw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.2.tgz", + "integrity": "sha512-VFLmfxkxo+U9q60tjcnSomQBRx2UzlRjKWJqvB4K1pUqsMQg4cu3QXA2nrcsj9A1qRsnJBbi2Ozx1hsiDoCkhw==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.8.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "license": "Apache-2.0", + "peerDependencies": { + "@discordjs/opus": ">=0.8.0 <1.0.0", + "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", + "node-opus": "^0.3.3", + "opusscript": "^0.0.8" + }, + "peerDependenciesMeta": { + "@discordjs/opus": { + "optional": true + }, + "ffmpeg-static": { + "optional": true + }, + "node-opus": { + "optional": true + }, + "opusscript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-addon": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", + "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", + "license": "Apache-2.0", + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sodium-native": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", + "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", + "license": "MIT", + "dependencies": { + "require-addon": "^1.1.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/server/package.json b/server/package.json index 9066851..cf39bef 100644 --- a/server/package.json +++ b/server/package.json @@ -10,15 +10,15 @@ "start": "node dist/index.js" }, "dependencies": { - "@discordjs/voice": "^0.18.0", "@discordjs/opus": "^0.9.0", + "@discordjs/voice": "^0.18.0", + "cors": "^2.8.5", "discord.js": "^14.16.3", "express": "^4.19.2", - "libsodium-wrappers": "^0.7.13", - "tweetnacl": "^1.0.3", - "sodium-native": "^4.0.8", - "cors": "^2.8.5", + "libsodium-wrappers": "^0.8.2", "multer": "^2.0.0", + "sodium-native": "^4.0.8", + "tweetnacl": "^1.0.3", "ws": "^8.18.0" }, "devDependencies": { @@ -29,5 +29,3 @@ "typescript": "^5.5.4" } } - - From 24929bbef28c2435f6a84e30cb1357ae33667391 Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 26 Feb 2026 17:02:27 +0100 Subject: [PATCH 04/22] feat(ci): add gitlab ci pipeline --- .gitlab-ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..b034943 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,57 @@ +stages: + - build + +variables: + IMAGE_NAME: $DOCKERHUB_USERNAME/discordsoundbot-vib + DOCKER_DRIVER: overlay2 + DOCKER_TLS_CERTDIR: "" + +docker-build: + stage: build + image: docker:24.0.5 + services: + - docker:24.0.5-dind + rules: + - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH + before_script: + - echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + script: + - | + if [ "$CI_COMMIT_REF_NAME" == "main" ]; then + export TAG="main" + export VERSION="1.1.0" + export CHANNEL="stable" + elif [[ "$CI_COMMIT_REF_NAME" == "feature/nightly" ]] || [[ "$CI_COMMIT_REF_NAME" == "nightly" ]]; then + export TAG="nightly" + export VERSION="1.1.0-nightly" + export CHANNEL="nightly" + else + export CLEAN_TAG=$(echo "$CI_COMMIT_REF_NAME" | sed 's/\//-/g') + export TAG="$CLEAN_TAG" + export VERSION="1.1.0-dev" + export CHANNEL="dev" + fi + + - echo "Building for channel $CHANNEL with version $VERSION and tag $TAG" + + # Build + - docker pull $IMAGE_NAME:$TAG || true + - > + docker build + --cache-from $IMAGE_NAME:$TAG + --build-arg VITE_BUILD_CHANNEL=$CHANNEL + --build-arg VITE_APP_VERSION=$VERSION + -t $IMAGE_NAME:$CI_COMMIT_SHA + -t $IMAGE_NAME:$TAG + . + + # Push + - docker push $IMAGE_NAME:$CI_COMMIT_SHA + - docker push $IMAGE_NAME:$TAG + + # If main branch, also tag and push as latest + - | + if [ "$CI_COMMIT_REF_NAME" == "main" ]; then + docker tag $IMAGE_NAME:$TAG $IMAGE_NAME:latest + docker push $IMAGE_NAME:latest + fi From 46a82b7daa7683ee95d36057cd7f16cbfd97eea3 Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 26 Feb 2026 17:11:47 +0100 Subject: [PATCH 05/22] chore: re-trigger build for dockerhub From 1feb7b0836744c227ce49ad38e20e6d9e64cfd3e Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 26 Feb 2026 21:08:22 +0100 Subject: [PATCH 06/22] fix(ci): override git clone url to local ip --- .gitlab-ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b034943..e43cb38 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,9 @@ variables: IMAGE_NAME: $DOCKERHUB_USERNAME/discordsoundbot-vib DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: "" + # Force clone via IP instead of hostname to bypass Unraid Docker DNS issues + CI_SERVER_URL: "http://10.10.10.10:9080" + GITLAB_FEATURES: "" docker-build: stage: build @@ -31,9 +34,9 @@ docker-build: export VERSION="1.1.0-dev" export CHANNEL="dev" fi - + - echo "Building for channel $CHANNEL with version $VERSION and tag $TAG" - + # Build - docker pull $IMAGE_NAME:$TAG || true - > From ba8c07f34759c7b49b1e301ae469bb5274d893ba Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 01:39:52 +0100 Subject: [PATCH 07/22] =?UTF-8?q?feat(web):=20complete=20frontend=20redesi?= =?UTF-8?q?gn=20=E2=80=94=20DECK=20theme=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/index.html | 11 +- web/package-lock.json | 1734 +++++++++++++++++++++++++++++++++++++++++ web/src/App.tsx | 694 +++++++++-------- web/src/styles.css | 1690 +++++++++++++++++++++++++++------------ 4 files changed, 3291 insertions(+), 838 deletions(-) create mode 100644 web/package-lock.json diff --git a/web/index.html b/web/index.html index e3aa098..34f6580 100644 --- a/web/index.html +++ b/web/index.html @@ -4,10 +4,11 @@ - Soundboard - - + + Jukebox + + + @@ -16,4 +17,4 @@
- \ No newline at end of file + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..dd263c5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1734 @@ +{ + "name": "discord-soundboard-web", + "version": "1.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord-soundboard-web", + "version": "1.1.1", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.3.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 7479572..59639cb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,81 +1,101 @@ -import React, { useEffect, useMemo, useRef, useState } 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 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 type { VoiceChannelInfo, Sound, Category } from './types'; 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() { + /* ── State ── */ const [sounds, setSounds] = useState([]); - const [total, setTotal] = useState(0); + const [total, setTotal] = useState(0); const [folders, setFolders] = useState>([]); - const [activeFolder, setActiveFolder] = useState('__all__'); const [categories, setCategories] = useState([]); - const [activeCategoryId, setActiveCategoryId] = useState(''); - const [channels, setChannels] = useState([]); + + const [activeTab, setActiveTab] = useState('all'); + const [activeFolder, setActiveFolder] = useState(''); const [query, setQuery] = useState(''); - const [selected, setSelected] = useState(''); - const selectedRef = useRef(''); - const [loading, setLoading] = useState(false); - const [notification, setNotification] = useState<{ msg: string, type: 'info' | 'error' } | null>(null); + const [channels, setChannels] = useState([]); + const [selected, setSelected] = useState(''); + const selectedRef = useRef(''); - const [volume, setVolume] = useState(1); + const [volume, setVolume] = useState(1); const [favs, setFavs] = useState>({}); - const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); - const [isAdmin, setIsAdmin] = useState(false); + const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'midnight'); + const [isAdmin, setIsAdmin] = useState(false); + const [showAdmin, setShowAdmin] = useState(false); - // Chaos Mode (Partymode) - const [chaosMode, setChaosMode] = useState(false); + const [chaosMode, setChaosMode] = useState(false); const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); - const chaosTimeoutRef = useRef(null); - const chaosModeRef = useRef(false); + const chaosModeRef = useRef(false); - // Scrolled State for Header blur - const [isScrolled, setIsScrolled] = useState(false); + const [lastPlayed, setLastPlayed] = useState(''); + const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); + /* ── Refs ── */ useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); 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 }); setTimeout(() => setNotification(null), 3000); - }; + }, []); - // ---------------- Init Load ---------------- + const guildId = selected ? selected.split(':')[0] : ''; + const channelId = selected ? selected.split(':')[1] : ''; + + /* ── Init ── */ useEffect(() => { (async () => { try { - const [c, selectedMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); - setChannels(c); - let initial = ''; - if (c.length > 0) { - const firstGuild = c[0].guildId; - const serverCid = selectedMap[firstGuild]; - if (serverCid && c.find(x => x.guildId === firstGuild && x.channelId === serverCid)) { - initial = `${firstGuild}:${serverCid}`; - } else { - initial = `${c[0].guildId}:${c[0].channelId}`; - } + const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); + setChannels(ch); + if (ch.length) { + const g = ch[0].guildId; + const serverCid = selMap[g]; + const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); + setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[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 { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch { } + try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } })(); }, []); - // ---------------- Theme ---------------- + /* ── Theme ── */ useEffect(() => { document.body.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme); + localStorage.setItem('jb-theme', theme); }, [theme]); - // ---------------- SSE Events ---------------- + /* ── SSE ── */ useEffect(() => { const unsub = subscribeEvents((msg) => { if (msg?.type === 'party') { - setPartyActiveGuilds((prev) => { + setPartyActiveGuilds(prev => { const s = new Set(prev); if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId); return Array.from(s); @@ -84,370 +104,418 @@ export default function App() { setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); try { const sel = msg?.selected || {}; - const currentSelected = selectedRef.current || ''; - const gid = currentSelected ? currentSelected.split(':')[0] : ''; - if (gid && sel[gid]) { - const newVal = `${gid}:${sel[gid]}`; - setSelected(newVal); - } + const g = selectedRef.current?.split(':')[0]; + if (g && sel[g]) setSelected(`${g}:${sel[g]}`); } catch { } try { const vols = msg?.volumes || {}; - const cur = selectedRef.current || ''; - const gid = cur ? cur.split(':')[0] : ''; - if (gid && typeof vols[gid] === 'number') { - setVolume(vols[gid]); - } + const g = selectedRef.current?.split(':')[0]; + if (g && typeof vols[g] === 'number') setVolume(vols[g]); } catch { } } else if (msg?.type === 'channel') { - try { - const gid = msg.guildId; - const cid = msg.channelId; - if (gid && cid) { - const currentSelected = selectedRef.current || ''; - const curGid = currentSelected ? currentSelected.split(':')[0] : ''; - if (curGid === gid) setSelected(`${gid}:${cid}`); - } - } catch { } + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); } else if (msg?.type === 'volume') { - try { - const gid = msg.guildId; - const v = msg.volume; - const cur = selectedRef.current || ''; - const curGid = cur ? cur.split(':')[0] : ''; - if (gid && curGid === gid && typeof v === 'number') { - setVolume(v); - } - } catch { } + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); } }); return () => { try { unsub(); } catch { } }; }, []); useEffect(() => { - const gid = selected ? selected.split(':')[0] : ''; - setChaosMode(gid ? partyActiveGuilds.includes(gid) : false); + setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); }, [selected, partyActiveGuilds]); - // ---------------- Data Fetching ---------------- + /* ── Data Fetch ── */ useEffect(() => { (async () => { try { - const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder; - const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, false); // Removed fuzzy param for cleaner UI + let folderParam = '__all__'; + if (activeTab === 'recent') folderParam = '__recent__'; + else if (activeFolder) folderParam = activeFolder; + + const s = await fetchSounds(query, folderParam, undefined, false); setSounds(s.items); setTotal(s.total); setFolders(s.folders); - } catch (e: any) { - showNotification(e?.message || 'Fehler beim Laden der Sounds', 'error'); - } + } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } })(); - }, [activeFolder, query, activeCategoryId]); + }, [activeTab, activeFolder, query]); + /* ── Favs persistence ── */ useEffect(() => { const c = getCookie('favs'); - if (c) { try { setFavs(JSON.parse(c)); } catch { } } + if (c) try { setFavs(JSON.parse(c)); } catch { } }, []); useEffect(() => { try { setCookie('favs', JSON.stringify(favs)); } catch { } }, [favs]); + /* ── Volume sync ── */ useEffect(() => { - (async () => { - if (selected) { - localStorage.setItem('selectedChannel', selected); - try { - const [guildId] = selected.split(':'); - const v = await getVolume(guildId); - setVolume(v); - } catch { } - } - })(); + if (selected) { + (async () => { + try { const v = await getVolume(guildId); setVolume(v); } catch { } + })(); + } }, [selected]); - // ---------------- Actions ---------------- - async function handlePlay(name: string, rel?: string) { - if (!selected) return showNotification('Bitte einen Voice-Channel auswählen', 'error'); - const [guildId, channelId] = selected.split(':'); + /* ── Actions ── */ + async function handlePlay(s: Sound) { + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); try { - setLoading(true); - await playSound(name, guildId, channelId, volume, rel); - } catch (e: any) { - showNotification(e?.message || 'Wiedergabe fehlgeschlagen', 'error'); - } finally { - setLoading(false); + await playSound(s.name, guildId, channelId, volume, s.relativePath); + setLastPlayed(s.name); + setTimeout(() => setLastPlayed(''), 4000); + } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } + } + + async function handleStop() { + if (!selected) return; + try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } + } + + async function handleRandom() { + if (!sounds.length || !selected) return; + const rnd = sounds[Math.floor(Math.random() * sounds.length)]; + handlePlay(rnd); + } + + async function toggleParty() { + 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 { } } } - // Chaos Mode Logic - const startChaosMode = async () => { - 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; + /* ── Computed ── */ + const displaySounds = useMemo(() => { + if (activeTab === 'favorites') { + return sounds.filter(s => favs[s.relativePath ?? s.fileName]); } - if (selected) { - const [guildId] = selected.split(':'); - try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } - } - }; + return sounds; + }, [sounds, activeTab, favs]); - const toggleChaosMode = async () => { - if (chaosMode) { - setChaosMode(false); - await stopChaosMode(); - 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 - const filtered = sounds; const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); - // Scroll Handler for Top Bar Blur - const handleScroll = (e: React.UIEvent) => { - setIsScrolled(e.currentTarget.scrollTop > 20); - }; + const visibleFolders = useMemo(() => + folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), + [folders]); + const folderColorMap = useMemo(() => { + const m: Record = {}; + 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 ( -
- {/* ---------------- Sidebar ---------------- */} - - - {/* ---------------- Main Content ---------------- */} -
-
-

- {activeFolder === '__all__' ? 'All Sounds' : - activeFolder === '__favs__' ? 'Favorites' : - activeFolder === '__recent__' ? 'Recently Added' : - folders.find(f => f.key === activeFolder)?.name.replace(/\s*\(\d+\)\s*$/, '') || 'Library'} -

- -
-
- search - setQuery(e.target.value)} - /> -
- -
-
+ )} +
-
-
- {(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => { - const key = `${s.relativePath ?? s.fileName}`; - const isFav = !!favs[key]; - return ( -
handlePlay(s.name, s.relativePath)}> - -
- music_note -
-
{s.name}
- - {Array.isArray((s as any).badges) && (s as any).badges!.includes('new') && ( -
NEW
- )} -
- ); - })} -
-
- - - {/* ---------------- Bottom Control Bar ---------------- */} -
-
- {/* Target Channel */} -
- headset_mic + {/* ════════ Control Bar ════════ */} +
+
+
+ headset_mic
+ + {lastPlayed && ( +
+ play_arrow + {lastPlayed} +
+ )}
-
- {/* Playback Controls */} - + + - -
-
- {/* Volume */} -
- volume_down +
+
+ { + 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'} + { + onChange={async e => { const v = parseFloat(e.target.value); setVolume(v); - try { (e.target as HTMLInputElement).style.setProperty('--_fill', `${Math.round(v * 100)}%`); } catch { } - if (selected) { const [guildId] = selected.split(':'); try { await setVolumeLive(guildId, v); } catch { } } + if (guildId) try { await setVolumeLive(guildId, v); } catch { } }} - style={{ ['--_fill' as any]: `${Math.round(volume * 100)}%` }} + style={{ '--fill': `${Math.round(volume * 100)}%` } as React.CSSProperties} /> - volume_up + {Math.round(volume * 100)}%
+ {/* ════════ Notification Toast ════════ */} {notification && ( -
- +
+ {notification.type === 'error' ? 'error_outline' : 'check_circle'} {notification.msg}
)} + {/* ════════ Admin Panel Overlay ════════ */} + {showAdmin && ( +
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> +
+

+ Admin + +

+ + {!isAdmin ? ( +
+
+ + setAdminPwd(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} + placeholder="Admin-Passwort..." + /> +
+ +
+ ) : ( +
+

+ Eingeloggt als Admin +

+ +
+ )} +
+
+ )}
); } diff --git a/web/src/styles.css b/web/src/styles.css index dd353d0..f514fe4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,650 +1,1300 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); +/* ═══════════════════════════════════════════════════════════════ + JUKEBOX — Discord Soundboard + Design: "DECK" — Cyberpunk Audio Console + Fonts: Syne (display) + Outfit (body) + ═══════════════════════════════════════════════════════════════ */ +@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap'); + +/* ──────────────────────────────────────────── + Theme: Midnight (default dark) + ──────────────────────────────────────────── */ :root { - color-scheme: light dark; + --bg-base: #0b0b0f; + --bg-surface-0: #101016; + --bg-surface-1: #16161e; + --bg-surface-2: #1e1e28; + --bg-surface-3: #282834; - /* Apple Light Theme Variables */ - --bg-color: #f5f5f7; - --bg-sidebar: rgba(245, 245, 247, 0.6); - --bg-player: rgba(255, 255, 255, 0.7); - --bg-card: #ffffff; - --bg-card-hover: #f0f0f2; - --bg-input: rgba(0, 0, 0, 0.05); + --text-primary: #e4e4ec; + --text-secondary: #7a7a90; + --text-muted: #4a4a5e; - --text-primary: #1d1d1f; - --text-secondary: #86868b; - --text-inverse: #ffffff; + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-glow: rgba(59, 130, 246, 0.15); + --accent-subtle: rgba(59, 130, 246, 0.08); - --border-color: rgba(0, 0, 0, 0.1); - --border-card: rgba(0, 0, 0, 0.05); + --danger: #ef4444; + --danger-glow: rgba(239, 68, 68, 0.15); + --success: #22c55e; + --warning: #f59e0b; - --accent-blue: #0071e3; - --accent-red: #ff3b30; - --accent-green: #34c759; + --border: rgba(255, 255, 255, 0.06); + --border-hover: rgba(255, 255, 255, 0.12); + --border-active: rgba(59, 130, 246, 0.4); - --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04); - --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 20px var(--accent-glow); - /* Dimensions */ - --sidebar-w: 260px; - --player-h: 80px; + --grid-opacity: 1; + --grid-dot: rgba(255, 255, 255, 0.03); + + --header-h: 64px; + --tabs-h: 44px; + --cats-h: 44px; + --control-h: 72px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-pill: 999px; + + color-scheme: dark; } -[data-theme="dark"] { - --bg-color: #000000; - --bg-sidebar: rgba(28, 28, 30, 0.5); - --bg-player: rgba(28, 28, 30, 0.65); - --bg-card: rgba(44, 44, 46, 0.7); - --bg-card-hover: rgba(58, 58, 60, 0.9); - --bg-input: rgba(255, 255, 255, 0.1); +/* ──────────────────────────────────────────── + Theme: Daylight + ──────────────────────────────────────────── */ +[data-theme="daylight"] { + --bg-base: #f4f2ee; + --bg-surface-0: #eae8e3; + --bg-surface-1: #ffffff; + --bg-surface-2: #f0eee9; + --bg-surface-3: #e4e2dd; - --text-primary: #f5f5f7; - --text-secondary: #86868b; - --text-inverse: #ffffff; + --text-primary: #1a1a2e; + --text-secondary: #6b6b80; + --text-muted: #a0a0b0; - --border-color: rgba(255, 255, 255, 0.1); - --border-card: rgba(255, 255, 255, 0.05); + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-glow: rgba(37, 99, 235, 0.1); + --accent-subtle: rgba(37, 99, 235, 0.05); - --accent-blue: #0a84ff; - --accent-red: #ff453a; - --accent-green: #32d74b; + --border: rgba(0, 0, 0, 0.08); + --border-hover: rgba(0, 0, 0, 0.14); + --border-active: rgba(37, 99, 235, 0.4); - --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.1); + --shadow-glow: 0 0 20px var(--accent-glow); + + --grid-opacity: 0; + --grid-dot: transparent; + + color-scheme: light; } -* { +/* ──────────────────────────────────────────── + Theme: Neon + ──────────────────────────────────────────── */ +[data-theme="neon"] { + --bg-base: #08080e; + --bg-surface-0: #0e0e18; + --bg-surface-1: #141422; + --bg-surface-2: #1c1c30; + --bg-surface-3: #24243c; + + --text-primary: #f0f0ff; + --text-secondary: #8080b0; + --text-muted: #505078; + + --accent: #e040fb; + --accent-hover: #d020eb; + --accent-glow: rgba(224, 64, 251, 0.2); + --accent-subtle: rgba(224, 64, 251, 0.08); + + --border: rgba(224, 64, 251, 0.08); + --border-hover: rgba(224, 64, 251, 0.18); + --border-active: rgba(224, 64, 251, 0.45); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 24px var(--accent-glow); + + --grid-opacity: 1; + --grid-dot: rgba(224, 64, 251, 0.025); + + color-scheme: dark; +} + +/* ──────────────────────────────────────────── + Theme: Vapor (Retrowave) + ──────────────────────────────────────────── */ +[data-theme="vapor"] { + --bg-base: #140a22; + --bg-surface-0: #1a0e2e; + --bg-surface-1: #22143a; + --bg-surface-2: #2c1c48; + --bg-surface-3: #362456; + + --text-primary: #e8daf8; + --text-secondary: #8a6aaa; + --text-muted: #5a4070; + + --accent: #06d6a0; + --accent-hover: #05c090; + --accent-glow: rgba(6, 214, 160, 0.18); + --accent-subtle: rgba(6, 214, 160, 0.07); + + --border: rgba(6, 214, 160, 0.07); + --border-hover: rgba(6, 214, 160, 0.15); + --border-active: rgba(6, 214, 160, 0.4); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 24px var(--accent-glow); + + --grid-opacity: 1; + --grid-dot: rgba(6, 214, 160, 0.02); + + color-scheme: dark; +} + +/* ──────────────────────────────────────────── + Theme: Matrix + ──────────────────────────────────────────── */ +[data-theme="matrix"] { + --bg-base: #050a05; + --bg-surface-0: #0a120a; + --bg-surface-1: #0f1a0f; + --bg-surface-2: #162216; + --bg-surface-3: #1e2e1e; + + --text-primary: #c0ecc0; + --text-secondary: #5a8a5a; + --text-muted: #2e5a2e; + + --accent: #22c55e; + --accent-hover: #16a34a; + --accent-glow: rgba(34, 197, 94, 0.18); + --accent-subtle: rgba(34, 197, 94, 0.06); + + --border: rgba(34, 197, 94, 0.07); + --border-hover: rgba(34, 197, 94, 0.15); + --border-active: rgba(34, 197, 94, 0.4); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7); + --shadow-glow: 0 0 24px var(--accent-glow); + + --grid-opacity: 1; + --grid-dot: rgba(34, 197, 94, 0.025); + + color-scheme: dark; +} + +/* ──────────────────────────────────────────── + Reset & Base + ──────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - background-color: var(--bg-color); + font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-base); color: var(--text-primary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overflow: hidden; - /* Prevent body scroll, layout handles it */ - height: 100vh; + height: 100dvh; width: 100vw; } -/* ---------------- Layout & Structure ---------------- */ - -.app-layout { - display: flex; - height: 100vh; - width: 100vw; - overflow: hidden; - position: relative; -} - -.sidebar { - width: var(--sidebar-w); - flex-shrink: 0; - background: var(--bg-sidebar); - border-right: 1px solid var(--border-color); - backdrop-filter: blur(25px) saturate(200%); - -webkit-backdrop-filter: blur(25px) saturate(200%); - display: flex; - flex-direction: column; - z-index: 10; - padding: 32px 16px; - overflow-y: auto; -} - -.main-content { - flex: 1; - display: flex; - flex-direction: column; - height: 100vh; - padding-bottom: var(--player-h); - /* Space for bottom player */ - overflow: hidden; - position: relative; -} - -/* ---------------- Typography ---------------- */ - -h1, -h2, -h3, -h4 { - font-weight: 700; - letter-spacing: -0.5px; -} - -.title-large { - font-size: 34px; - font-weight: 800; - letter-spacing: -1px; - margin-bottom: 24px; -} - -.sidebar-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; - color: var(--text-secondary); - margin: 24px 0 8px 12px; -} - -/* ---------------- Sidebar Items ---------------- */ - -.nav-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-radius: 8px; - cursor: pointer; - color: var(--text-primary); - font-size: 14px; - font-weight: 500; - transition: background 0.2s ease; - margin-bottom: 2px; - border: none; - background: transparent; - width: 100%; - text-align: left; -} - -.nav-item:hover { - background: var(--bg-card); -} - -.nav-item.active { - background: var(--bg-input); - color: var(--accent-blue); - font-weight: 600; -} - -/* ---------------- Header Area ---------------- */ - -.top-bar { - padding: 24px 40px; - display: flex; - justify-content: space-between; - align-items: flex-end; - border-bottom: 1px solid transparent; - backdrop-filter: blur(20px); - position: sticky; - top: 0; - z-index: 5; - transition: all 0.3s ease; -} - -.top-bar.scrolled { - background: var(--bg-player); - border-bottom: 1px solid var(--border-color); - padding: 16px 40px; -} - -.top-bar .title-large { - margin: 0; - transition: font-size 0.3s ease; -} - -.top-bar.scrolled .title-large { - font-size: 24px; -} - -.header-actions { - display: flex; - gap: 12px; - align-items: center; -} - -/* ---------------- Search & Inputs ---------------- */ - -.search-box { - position: relative; - width: 280px; -} - -.input-modern { - width: 100%; - background: var(--bg-input); - border: 1px solid transparent; - border-radius: 12px; - padding: 12px 16px 12px 36px; - font-size: 14px; - color: var(--text-primary); - transition: all 0.2s ease; +button { font-family: inherit; + cursor: pointer; + border: none; + background: none; + color: inherit; } -.input-modern:focus { - outline: none; - border-color: var(--accent-blue); - box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.2); - background: var(--bg-card); +input, select { + font-family: inherit; + color: inherit; } -[data-theme="dark"] .input-modern:focus { - box-shadow: 0 0 0 4px rgba(10, 132, 255, 0.3); +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); } -.search-icon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--text-secondary); - font-size: 18px; +::selection { + background: var(--accent); + color: white; } -/* ---------------- Track Grid ---------------- */ - -.track-container { - padding: 0 40px 40px 40px; - overflow-y: auto; - flex: 1; -} - -.track-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 20px; -} - -.track-card { - background: var(--bg-card); - border: 1px solid var(--border-card); - border-radius: 16px; - padding: 20px; +/* ──────────────────────────────────────────── + App Shell + ──────────────────────────────────────────── */ +.app-shell { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); - box-shadow: var(--shadow-sm); - position: relative; - aspect-ratio: 1 / 1; -} - -.track-card:hover { - transform: scale(1.03) translateY(-4px); - box-shadow: var(--shadow-md); - background: var(--bg-card-hover); -} - -.track-icon { - width: 64px; - height: 64px; - background: linear-gradient(135deg, var(--bg-input), var(--border-color)); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; - font-size: 28px; - color: var(--text-secondary); - transition: all 0.3s ease; -} - -.track-card:hover .track-icon { - background: var(--accent-blue); - color: white; - transform: scale(1.1); - box-shadow: 0 8px 16px rgba(0, 113, 227, 0.4); -} - -.track-name { - font-weight: 600; - font-size: 15px; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + height: 100dvh; + width: 100vw; overflow: hidden; - text-overflow: ellipsis; - word-break: break-word; + position: relative; } -.fav-btn { - position: absolute; - top: 12px; - right: 12px; - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - opacity: 0; - transition: all 0.2s ease; - font-size: 20px; +/* Dot grid pattern overlay */ +.app-shell::after { + content: ''; + position: fixed; + inset: 0; + opacity: var(--grid-opacity); + background-image: radial-gradient( + circle, var(--grid-dot) 1px, transparent 1px + ); + background-size: 28px 28px; + pointer-events: none; + z-index: 0; } -.track-card:hover .fav-btn, -.fav-btn.active { - opacity: 1; -} - -.fav-btn.active { - color: #ff9f0a; - /* Apple Orange */ -} - -/* ---------------- Bottom Control Bar ---------------- */ - -.bottom-player { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: var(--player-h); - background: var(--bg-player); - border-top: 1px solid var(--border-color); - backdrop-filter: blur(30px) saturate(200%); - -webkit-backdrop-filter: blur(30px) saturate(200%); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 32px; - z-index: 20; -} - -.player-section { +/* ──────────────────────────────────────────── + Header + ──────────────────────────────────────────── */ +.header { + height: var(--header-h); + min-height: var(--header-h); display: flex; align-items: center; gap: 16px; + padding: 0 24px; + background: var(--bg-surface-0); + border-bottom: 1px solid var(--border); + position: relative; + z-index: 20; +} + +.logo { + font-family: 'Syne', sans-serif; + font-weight: 800; + font-size: 22px; + letter-spacing: -0.5px; + background: linear-gradient(135deg, var(--accent), var(--text-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + flex-shrink: 0; + user-select: none; +} + +.header-search { flex: 1; + max-width: 420px; + position: relative; } -.player-section.center { - justify-content: center; - flex: 2; +.header-search .search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + font-size: 18px; + color: var(--text-muted); + pointer-events: none; } -.player-section.right { - justify-content: flex-end; -} - -/* ---------------- Buttons & Controls ---------------- */ - -.btn-icon { - width: 40px; - height: 40px; - border-radius: 50%; - border: none; - background: transparent; +.header-search input { + width: 100%; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 36px 8px 38px; + font-size: 13.5px; + font-weight: 400; color: var(--text-primary); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; -} - -.btn-icon:hover { - background: var(--bg-input); -} - -.btn-icon.active { - color: var(--accent-blue); -} - -.btn-primary { - background: var(--accent-blue); - color: white; - border: none; - padding: 10px 20px; - border-radius: 999px; - font-weight: 600; - font-size: 14px; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 4px 12px rgba(0, 113, 227, 0.3); -} - -.btn-primary:hover { - transform: scale(1.05); - filter: brightness(1.1); -} - -.btn-danger { - background: rgba(255, 59, 48, 0.1); - color: var(--accent-red); - border: none; - padding: 10px 20px; - border-radius: 999px; - font-weight: 600; - font-size: 14px; - cursor: pointer; transition: all 0.2s ease; } -.btn-danger:hover { - background: var(--accent-red); - color: white; - box-shadow: 0 4px 12px rgba(255, 59, 48, 0.3); +.header-search input::placeholder { + color: var(--text-muted); } -/* Partymode Chaos Animation Style */ -.btn-chaos { - background: linear-gradient(45deg, #ff2a6d, #d1f7ff, #05d9e8, #01012b); - background-size: 300% 300%; - color: white; - border: none; - padding: 10px 20px; - border-radius: 999px; - font-weight: 600; - font-size: 14px; - cursor: pointer; - animation: bgPulse 3s ease infinite; - box-shadow: 0 4px 15px rgba(5, 217, 232, 0.5); - transition: transform 0.2s; -} - -.btn-chaos:hover { - transform: scale(1.05); -} - -@keyframes bgPulse { - 0% { - background-position: 0% 50%; - } - - 50% { - background-position: 100% 50%; - } - - 100% { - background-position: 0% 50%; - } -} - -/* ---------------- Select & Volume ---------------- */ - -.select-modern { - appearance: none; - background: var(--bg-input); - border: 1px solid transparent; - padding: 8px 36px 8px 16px; - border-radius: 999px; - color: var(--text-primary); - font-weight: 500; - font-size: 13px; - cursor: pointer; - transition: all 0.2s ease; - font-family: inherit; -} - -.select-modern:hover { - background: var(--bg-card); - border-color: var(--border-color); -} - -.select-modern:focus { +.header-search input:focus { outline: none; - border-color: var(--accent-blue); - box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.2); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); + background: var(--bg-surface-1); } -.volume-container { +.header-search .search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border-radius: 50%; display: flex; align-items: center; + justify-content: center; + color: var(--text-muted); + transition: all 0.15s; +} + +.header-search .search-clear:hover { + background: var(--bg-surface-3); + color: var(--text-primary); +} + +.header-meta { + display: flex; + align-items: center; + gap: 16px; + margin-left: auto; + flex-shrink: 0; +} + +.sound-count { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sound-count strong { + color: var(--accent); + font-weight: 700; +} + +/* Theme & Channel selects */ +.select-clean { + appearance: none; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 28px 6px 10px; + font-size: 12.5px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + transition: all 0.15s; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237a7a90' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; +} + +.select-clean:hover { + border-color: var(--border-hover); + background-color: var(--bg-surface-3); +} + +.select-clean:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.select-clean option { + background: var(--bg-surface-1); + color: var(--text-primary); +} + +/* ──────────────────────────────────────────── + Tab Bar + ──────────────────────────────────────────── */ +.tab-bar { + height: var(--tabs-h); + min-height: var(--tabs-h); + display: flex; + align-items: stretch; + gap: 0; + padding: 0 24px; + background: var(--bg-surface-0); + border-bottom: 1px solid var(--border); + position: relative; + z-index: 15; +} + +.tab-btn { + position: relative; + padding: 0 18px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + transition: color 0.2s; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.tab-btn:hover { + color: var(--text-primary); +} + +.tab-btn.active { + color: var(--accent); +} + +.tab-btn.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 12px; + right: 12px; + height: 2px; + background: var(--accent); + border-radius: 2px 2px 0 0; +} + +.tab-badge { + font-size: 10.5px; + font-weight: 700; + background: var(--accent-subtle); + color: var(--accent); + padding: 1px 6px; + border-radius: var(--radius-pill); + font-variant-numeric: tabular-nums; +} + +/* ──────────────────────────────────────────── + Category Filter Strip + ──────────────────────────────────────────── */ +.category-strip { + min-height: var(--cats-h); + display: flex; + align-items: center; + gap: 6px; + padding: 0 24px; + background: var(--bg-surface-0); + border-bottom: 1px solid var(--border); + overflow-x: auto; + overflow-y: hidden; + position: relative; + z-index: 14; + scrollbar-width: none; +} + +.category-strip::-webkit-scrollbar { + display: none; +} + +.cat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + background: var(--bg-surface-2); + border: 1px solid var(--border); + white-space: nowrap; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.cat-chip:hover { + border-color: var(--border-hover); + color: var(--text-primary); + background: var(--bg-surface-3); +} + +.cat-chip.active { + background: var(--accent-subtle); + border-color: var(--accent); + color: var(--accent); +} + +.cat-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ──────────────────────────────────────────── + Sounds Area & Grid + ──────────────────────────────────────────── */ +.sounds-area { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + position: relative; + z-index: 1; +} + +.sounds-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 16px 24px; + padding-bottom: calc(var(--control-h) + 24px); + align-content: flex-start; +} + +.sounds-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + color: var(--text-muted); + text-align: center; + width: 100%; +} + +.sounds-empty .material-icons { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.4; +} + +.sounds-empty p { + font-size: 14px; + font-weight: 500; +} + +/* ── Sound Button (Compact Pill) ── */ +.sound-btn { + display: inline-flex; + align-items: center; + gap: 0; + height: 36px; + padding: 0 12px 0 0; + border-radius: var(--radius-sm); + background: var(--bg-surface-1); + border: 1px solid var(--border); + font-size: 12.5px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + transition: all 0.12s ease; + position: relative; + overflow: hidden; + max-width: 220px; + flex-shrink: 0; +} + +.sound-btn .cat-bar { + width: 3px; + height: 100%; + flex-shrink: 0; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} + +.sound-btn .sound-label { + padding: 0 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 36px; +} + +.sound-btn:hover { + background: var(--bg-surface-2); + border-color: var(--border-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.sound-btn:active { + transform: scale(0.96); + box-shadow: none; + transition-duration: 0.05s; +} + +.sound-btn .fav-star { + position: absolute; + right: 3px; + top: 50%; + transform: translateY(-50%); + font-size: 14px; + opacity: 0; + color: var(--text-muted); + transition: all 0.15s; + padding: 2px; + line-height: 1; +} + +.sound-btn:hover .fav-star { + opacity: 0.6; +} + +.sound-btn .fav-star.is-fav { + opacity: 1; + color: var(--warning); +} + +.sound-btn:hover .fav-star.is-fav { + opacity: 1; +} + +/* Playing animation */ +.sound-btn.is-playing { + border-color: var(--accent); + box-shadow: var(--shadow-glow); +} + +.sound-btn.is-playing::after { + content: ''; + position: absolute; + inset: 0; + background: var(--accent-glow); + animation: pulse-bg 1s ease infinite; + pointer-events: none; +} + +/* Badge indicators */ +.sound-btn .badge-dot { + position: absolute; + top: 3px; + right: 3px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); +} + +.sound-btn .badge-dot.new { + background: var(--success); +} + +.sound-btn .badge-dot.top { + background: var(--warning); +} + +/* ──────────────────────────────────────────── + Control Bar (Bottom) + ──────────────────────────────────────────── */ +.control-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: var(--control-h); + background: var(--bg-surface-0); + border-top: 1px solid var(--border); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + display: flex; + align-items: center; + padding: 0 24px; gap: 12px; - width: 140px; + z-index: 30; +} + +.ctrl-section { + display: flex; + align-items: center; + gap: 8px; +} + +.ctrl-section.left { + flex: 1; + min-width: 0; +} + +.ctrl-section.center { + flex-shrink: 0; +} + +.ctrl-section.right { + flex: 1; + justify-content: flex-end; + min-width: 0; +} + +/* Channel select in control bar */ +.channel-wrap { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.channel-wrap .material-icons { + font-size: 18px; + color: var(--text-muted); + flex-shrink: 0; +} + +.channel-select { + appearance: none; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 28px 6px 10px; + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + min-width: 160px; + max-width: 280px; + transition: all 0.15s; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237a7a90' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; +} + +.channel-select:hover { + border-color: var(--border-hover); +} + +.channel-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.channel-select option { + background: var(--bg-surface-1); +} + +/* ── Control Buttons ── */ +.ctrl-btn { + height: 38px; + padding: 0 16px; + border-radius: var(--radius-pill); + font-size: 12.5px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s ease; + white-space: nowrap; +} + +.ctrl-btn .material-icons { + font-size: 18px; +} + +/* Stop */ +.ctrl-btn.stop { + background: var(--danger-glow); + color: var(--danger); +} +.ctrl-btn.stop:hover { + background: var(--danger); + color: white; + box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); +} + +/* Random / Shuffle */ +.ctrl-btn.shuffle { + background: var(--accent); + color: white; +} +.ctrl-btn.shuffle:hover { + background: var(--accent-hover); + box-shadow: 0 4px 16px var(--accent-glow); + transform: scale(1.03); +} + +/* Party Mode */ +.ctrl-btn.party { + background: var(--bg-surface-2); + color: var(--text-secondary); + border: 1px solid var(--border); +} +.ctrl-btn.party:hover { + border-color: var(--border-hover); + color: var(--text-primary); + background: var(--bg-surface-3); +} + +.ctrl-btn.party.active { + background: linear-gradient(135deg, #ec4899, #8b5cf6, #3b82f6, #06b6d4); + background-size: 300% 300%; + animation: party-gradient 4s ease infinite; + color: white; + border: none; + box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4); +} + +.ctrl-btn.party.active:hover { + transform: scale(1.03); +} + +/* ── Volume ── */ +.volume-wrap { + display: flex; + align-items: center; + gap: 8px; + width: 150px; + flex-shrink: 0; +} + +.volume-wrap .material-icons { + font-size: 16px; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; +} + +.volume-wrap .material-icons:hover { + color: var(--text-secondary); } .volume-slider { + -webkit-appearance: none; appearance: none; width: 100%; height: 4px; - background: var(--border-color); + background: var(--bg-surface-3); border-radius: 2px; outline: none; - background-image: linear-gradient(var(--text-secondary), var(--text-secondary)); - background-size: var(--_fill, 0%) 100%; + position: relative; + background-image: linear-gradient(var(--accent), var(--accent)); + background-size: var(--fill, 100%) 100%; background-repeat: no-repeat; } .volume-slider::-webkit-slider-thumb { - appearance: none; - width: 16px; - height: 16px; + -webkit-appearance: none; + width: 14px; + height: 14px; border-radius: 50%; background: var(--text-primary); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); cursor: pointer; + transition: transform 0.1s; } -/* ---------------- Admin Badges & Checkboxes ---------------- */ +.volume-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} -.admin-checkbox { - position: absolute; - top: 12px; - left: 12px; - appearance: none; - width: 20px; - height: 20px; - border: 2px solid var(--border-color); - border-radius: 6px; - background: var(--bg-card); +.volume-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-primary); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); cursor: pointer; - transition: all 0.2s; - z-index: 2; + border: none; } -.admin-checkbox:checked { - background: var(--accent-blue) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E") no-repeat center center; - background-size: 14px; - border-color: var(--accent-blue); +.volume-pct { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + min-width: 30px; + text-align: right; + font-variant-numeric: tabular-nums; } -.badge-new { - position: absolute; - bottom: 0; - left: 50%; - transform: translate(-50%, 50%); - background: var(--accent-blue); - color: white; - font-size: 10px; - font-weight: 700; - padding: 2px 8px; - border-radius: 10px; - box-shadow: 0 2px 8px rgba(0, 113, 227, 0.4); +/* ── Now Playing ── */ +.now-playing { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.badge-custom { - position: absolute; - bottom: -4px; - right: -4px; - font-size: 20px; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); +.now-playing span { + color: var(--accent); } -/* ---------------- Error & Notification ---------------- */ - -.notification { +/* ──────────────────────────────────────────── + Notifications / Toast + ──────────────────────────────────────────── */ +.toast { position: fixed; - bottom: calc(var(--player-h) + 20px); + bottom: calc(var(--control-h) + 16px); left: 50%; transform: translateX(-50%); - padding: 12px 24px; - border-radius: 999px; - font-weight: 500; - font-size: 14px; + padding: 10px 20px; + border-radius: var(--radius-pill); + font-size: 13px; + font-weight: 600; z-index: 100; - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - box-shadow: var(--shadow-lg); display: flex; align-items: center; gap: 8px; - animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + box-shadow: var(--shadow-lg); + animation: toast-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + pointer-events: none; } -@keyframes slideUp { +.toast .material-icons { + font-size: 16px; +} + +.toast.error { + background: var(--danger); + color: white; +} + +.toast.info { + background: var(--success); + color: white; +} + +/* ──────────────────────────────────────────── + Admin Panel (Overlay) + ──────────────────────────────────────────── */ +.admin-toggle { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: all 0.15s; +} + +.admin-toggle:hover { + background: var(--bg-surface-2); + color: var(--text-primary); +} + +.admin-toggle.is-admin { + color: var(--accent); +} + +.admin-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 0.2s ease; +} + +.admin-panel { + background: var(--bg-surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 28px; + width: 90%; + max-width: 480px; + max-height: 80vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.admin-panel h3 { + font-family: 'Syne', sans-serif; + font-size: 18px; + font-weight: 700; + margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.admin-panel .admin-close { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.15s; +} + +.admin-panel .admin-close:hover { + background: var(--bg-surface-3); + color: var(--text-primary); +} + +.admin-field { + margin-bottom: 16px; +} + +.admin-field label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.admin-field input { + width: 100%; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + font-size: 14px; + transition: all 0.15s; +} + +.admin-field input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.admin-btn { + padding: 10px 20px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + transition: all 0.15s; +} + +.admin-btn.primary { + background: var(--accent); + color: white; +} +.admin-btn.primary:hover { + background: var(--accent-hover); +} + +.admin-btn.outline { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); +} +.admin-btn.outline:hover { + border-color: var(--border-hover); + color: var(--text-primary); +} + +.admin-btn.danger { + background: var(--danger-glow); + color: var(--danger); +} +.admin-btn.danger:hover { + background: var(--danger); + color: white; +} + +/* ──────────────────────────────────────────── + Party Mode Global Effects + ──────────────────────────────────────────── */ +.app-shell.party-active .header { + border-bottom-color: transparent; + background: linear-gradient(90deg, + rgba(236, 72, 153, 0.08), + rgba(139, 92, 246, 0.08), + rgba(59, 130, 246, 0.08), + rgba(6, 182, 212, 0.08) + ); + background-size: 400% 100%; + animation: party-gradient 6s ease infinite; +} + +.app-shell.party-active .control-bar { + border-top-color: transparent; + box-shadow: 0 -2px 20px rgba(139, 92, 246, 0.15); +} + +/* ──────────────────────────────────────────── + Animations & Keyframes + ──────────────────────────────────────────── */ +@keyframes party-gradient { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes pulse-bg { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.6; } +} + +@keyframes toast-in { from { - transform: translate(-50%, 20px); + transform: translate(-50%, 16px); opacity: 0; } - to { transform: translate(-50%, 0); opacity: 1; } } -.notification.error { - background: rgba(255, 59, 48, 0.9); - color: white; +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } } -.notification.info { - background: rgba(52, 199, 89, 0.9); - color: white; +@keyframes spin { + to { transform: rotate(360deg); } } -/* Responsive */ +/* Staggered entrance for sound buttons */ +.sounds-grid .sound-btn { + animation: sound-enter 0.2s ease both; +} + +@keyframes sound-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ──────────────────────────────────────────── + Responsive + ──────────────────────────────────────────── */ @media (max-width: 768px) { - .app-layout { - flex-direction: column; + .header { + padding: 0 16px; + gap: 10px; + height: auto; + min-height: 56px; + flex-wrap: wrap; + padding-top: 10px; + padding-bottom: 10px; } - .sidebar { - width: 100%; - height: 60px; - flex-direction: row; - padding: 10px; - border-right: none; - border-bottom: 1px solid var(--border-color); - overflow-x: auto; - overflow-y: hidden; + .logo { + font-size: 18px; } - .sidebar-title { + .header-search { + order: 10; + max-width: 100%; + flex-basis: 100%; + } + + .header-meta { + gap: 8px; + } + + .sound-count { display: none; } - .nav-item { - white-space: nowrap; - margin-right: 8px; - margin-bottom: 0; - width: auto; + .tab-bar { + padding: 0 16px; + overflow-x: auto; + scrollbar-width: none; } - .main-content { - padding-bottom: calc(var(--player-h) + 60px); + .tab-bar::-webkit-scrollbar { + display: none; } - .bottom-player { - flex-direction: column; + .category-strip { + padding: 0 16px; + } + + .sounds-grid { + padding: 12px 16px; + gap: 5px; + } + + .sound-btn { + font-size: 11.5px; + height: 32px; + max-width: 180px; + } + + .control-bar { + padding: 0 12px; height: auto; - padding: 16px; - gap: 16px; + min-height: var(--control-h); + flex-wrap: wrap; + padding-top: 10px; + padding-bottom: 10px; + gap: 8px; } - .search-box { - width: 100%; + .ctrl-section.left { + order: 1; + flex-basis: 100%; } -} \ No newline at end of file + + .ctrl-section.center { + order: 2; + } + + .ctrl-section.right { + order: 3; + } + + .volume-wrap { + width: 120px; + } + + .channel-select { + min-width: 120px; + } + + .sounds-grid { + padding-bottom: calc(140px + 24px); + } + + .toast { + bottom: 150px; + } +} + +@media (max-width: 480px) { + .ctrl-btn span:not(.material-icons) { + display: none; + } + + .ctrl-btn { + padding: 0 10px; + } + + .volume-wrap { + width: 100px; + } + + .now-playing { + display: none; + } +} + +/* ──────────────────────────────────────────── + Utility + ──────────────────────────────────────────── */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* Vapor theme gradient overlay */ +[data-theme="vapor"] .app-shell::before { + content: ''; + position: fixed; + inset: 0; + background: linear-gradient(180deg, + rgba(120, 40, 200, 0.06) 0%, + transparent 40%, + rgba(6, 214, 160, 0.04) 100% + ); + pointer-events: none; + z-index: 0; +} From e0bbe038512a0ac34ba3807359e1ce7b00a5a396 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 02:03:04 +0100 Subject: [PATCH 08/22] feat(web): add button size toggle (S/M/L) in header Adds a size selector in the header bar that allows users to choose between Small, Medium, and Large sound buttons. Choice persists via localStorage. Responsive breakpoints also respect the setting. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 26 +++++++++- web/src/styles.css | 117 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 59639cb..c11438c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -25,6 +25,12 @@ const THEMES = [ ]; type Tab = 'all' | 'favorites' | 'recent'; +type BtnSize = 'S' | 'M' | 'L'; +const BTN_SIZES: { id: BtnSize; label: string }[] = [ + { id: 'S', label: 'S' }, + { id: 'M', label: 'M' }, + { id: 'L', label: 'L' }, +]; export default function App() { /* ── State ── */ @@ -44,6 +50,7 @@ export default function App() { const [volume, setVolume] = useState(1); const [favs, setFavs] = useState>({}); const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'midnight'); + const [btnSize, setBtnSize] = useState(() => (localStorage.getItem('jb-btn-size') as BtnSize) || 'M'); const [isAdmin, setIsAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false); @@ -91,6 +98,11 @@ export default function App() { localStorage.setItem('jb-theme', theme); }, [theme]); + /* ── Button Size ── */ + useEffect(() => { + localStorage.setItem('jb-btn-size', btnSize); + }, [btnSize]); + /* ── SSE ── */ useEffect(() => { const unsub = subscribeEvents((msg) => { @@ -230,7 +242,7 @@ export default function App() { /* ── Render ── */ return ( -
+
{/* ════════ Header ════════ */}
@@ -256,6 +268,18 @@ export default function App() { {total} Sounds
+
+ {BTN_SIZES.map(s => ( + + ))} +
+ setQuery(e.target.value)} /> {query && ( )}
-
-
- {total} Sounds -
+
-
- {BTN_SIZES.map(s => ( - - ))} -
+ - + - + + +
+ grid_view + setCardSize(parseInt(e.target.value))} + />
- - {/* ════════ Tab Bar ════════ */} - +
+ {THEMES.map(t => ( +
setTheme(t.id)} + /> + ))} +
+
- {/* ════════ Category Filter ════════ */} + {/* ═══ FOLDER CHIPS ═══ */} {activeTab === 'all' && visibleFolders.length > 0 && (
{visibleFolders.map(f => { @@ -338,169 +466,185 @@ export default function App() { key={f.key} className={`cat-chip ${isActive ? 'active' : ''}`} onClick={() => setActiveFolder(isActive ? '' : f.key)} - style={isActive ? { borderColor: color, color, background: `${color}12` } : undefined} + style={isActive ? { borderColor: color, color } : undefined} > {f.name.replace(/\s*\(\d+\)\s*$/, '')} - {f.count} + {f.count} ); })}
)} - {/* ════════ Sound Grid ════════ */} -
+ {/* ═══ MAIN ═══ */} +
{displaySounds.length === 0 ? ( -
- - {activeTab === 'favorites' ? 'star_border' : 'music_off'} - -

+

+
{activeTab === 'favorites' ? '⭐' : '🔇'}
+
{activeTab === 'favorites' - ? 'Noch keine Favorites — klick den Stern!' + ? 'Noch keine Favoriten' : query ? `Kein Sound für "${query}" gefunden` : 'Keine Sounds vorhanden'} -

+
+
+ {activeTab === 'favorites' + ? 'Klick den Stern auf einem Sound!' + : 'Hier gibt\'s noch nichts zu hören.'} +
) : ( -
+
{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; + const isNew = s.isRecent || s.badges?.includes('new'); + const initial = s.name.charAt(0).toUpperCase(); + const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; return ( - + {initial} + {s.name} + {s.folder && {s.folder}} +
+
+
+
+
); })}
)} +
+ + {/* ═══ BOTTOM BAR ═══ */} +
+
+
+
+
+
+ Spielt: + {lastPlayed || '—'} +
+
+ { + 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'} + + { + const v = parseFloat(e.target.value); + setVolume(v); + if (guildId) try { await setVolumeLive(guildId, v); } catch { } + }} + style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} + /> + {Math.round(volume * 100)}% +
- {/* ════════ Control Bar ════════ */} -
-
-
- headset_mic - + {/* ═══ CONTEXT MENU ═══ */} + {ctxMenu && ( +
e.stopPropagation()} + > +
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> + play_arrow + Abspielen
- - {lastPlayed && ( -
- play_arrow - {lastPlayed} -
+
{ + toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); + setCtxMenu(null); + }}> + + {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} + + Favorit +
+ {isAdmin && ( + <> +
+
{ + const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; + try { + await adminDelete([path]); + notify('Sound gelöscht'); + setRefreshKey(k => k + 1); + } catch { notify('Löschen fehlgeschlagen', 'error'); } + setCtxMenu(null); + }}> + delete + Löschen +
+ )}
+ )} -
- - - - - -
- -
-
- { - 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'} - - { - const v = parseFloat(e.target.value); - setVolume(v); - if (guildId) try { await setVolumeLive(guildId, v); } catch { } - }} - style={{ '--fill': `${Math.round(volume * 100)}%` } as React.CSSProperties} - /> - {Math.round(volume * 100)}% -
-
-
- - {/* ════════ Notification Toast ════════ */} + {/* ═══ TOAST ═══ */} {notification && (
- + {notification.type === 'error' ? 'error_outline' : 'check_circle'} {notification.msg}
)} - {/* ════════ Admin Panel Overlay ════════ */} + {/* ═══ ADMIN PANEL ═══ */} {showAdmin && (
{ if (e.target === e.currentTarget) setShowAdmin(false); }}>
@@ -510,11 +654,10 @@ export default function App() { close - {!isAdmin ? (
- +
- +
) : (
-

- Eingeloggt als Admin -

- +

Eingeloggt als Admin

+
)}
diff --git a/web/src/styles.css b/web/src/styles.css index ea11fb8..92185be 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,193 +1,93 @@ /* ═══════════════════════════════════════════════════════════════ JUKEBOX — Discord Soundboard - Design: "DECK" — Cyberpunk Audio Console - Fonts: Syne (display) + Outfit (body) + Design: Discord-style dark theme with Blurple accent + Font: DM Sans (display + body) ═══════════════════════════════════════════════════════════════ */ -@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap'); /* ──────────────────────────────────────────── - Theme: Midnight (default dark) + Theme Variables — Default (Discord Blurple) ──────────────────────────────────────────── */ :root { - --bg-base: #0b0b0f; - --bg-surface-0: #101016; - --bg-surface-1: #16161e; - --bg-surface-2: #1e1e28; - --bg-surface-3: #282834; + --bg-deep: #1a1b1e; + --bg-primary: #1e1f22; + --bg-secondary: #2b2d31; + --bg-tertiary: #313338; + --bg-modifier-hover: rgba(79, 84, 92, .16); + --bg-modifier-active: rgba(79, 84, 92, .24); + --bg-modifier-selected: rgba(79, 84, 92, .32); - --text-primary: #e4e4ec; - --text-secondary: #7a7a90; - --text-muted: #4a4a5e; + --text-normal: #dbdee1; + --text-muted: #949ba4; + --text-faint: #6d6f78; - --accent: #3b82f6; - --accent-hover: #2563eb; - --accent-glow: rgba(59, 130, 246, 0.15); - --accent-subtle: rgba(59, 130, 246, 0.08); + --accent: #5865f2; + --accent-hover: #4752c4; + --accent-glow: rgba(88, 101, 242, .45); - --danger: #ef4444; - --danger-glow: rgba(239, 68, 68, 0.15); - --success: #22c55e; - --warning: #f59e0b; + --green: #23a55a; + --red: #f23f42; + --yellow: #f0b232; + --white: #ffffff; - --border: rgba(255, 255, 255, 0.06); - --border-hover: rgba(255, 255, 255, 0.12); - --border-active: rgba(59, 130, 246, 0.4); + --font: 'DM Sans', 'Outfit', 'gg sans', 'Noto Sans', Whitney, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --radius: 8px; + --radius-lg: 12px; + --shadow-low: 0 1px 3px rgba(0, 0, 0, .24); + --shadow-med: 0 4px 12px rgba(0, 0, 0, .32); + --shadow-high: 0 8px 24px rgba(0, 0, 0, .4); + --transition: 150ms cubic-bezier(.4, 0, .2, 1); - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); - --shadow-glow: 0 0 20px var(--accent-glow); - - --grid-opacity: 1; - --grid-dot: rgba(255, 255, 255, 0.03); - - --header-h: 64px; - --tabs-h: 44px; - --cats-h: 44px; - --control-h: 72px; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 16px; - --radius-pill: 999px; + --card-size: 110px; + --card-emoji: 28px; + --card-font: 11px; color-scheme: dark; } -/* ──────────────────────────────────────────── - Theme: Daylight - ──────────────────────────────────────────── */ -[data-theme="daylight"] { - --bg-base: #f4f2ee; - --bg-surface-0: #eae8e3; - --bg-surface-1: #ffffff; - --bg-surface-2: #f0eee9; - --bg-surface-3: #e4e2dd; - - --text-primary: #1a1a2e; - --text-secondary: #6b6b80; - --text-muted: #a0a0b0; - - --accent: #2563eb; - --accent-hover: #1d4ed8; - --accent-glow: rgba(37, 99, 235, 0.1); - --accent-subtle: rgba(37, 99, 235, 0.05); - - --border: rgba(0, 0, 0, 0.08); - --border-hover: rgba(0, 0, 0, 0.14); - --border-active: rgba(37, 99, 235, 0.4); - - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.1); - --shadow-glow: 0 0 20px var(--accent-glow); - - --grid-opacity: 0; - --grid-dot: transparent; - - color-scheme: light; +/* ── Theme: Midnight Purple ── */ +[data-theme="purple"] { + --bg-deep: #13111c; + --bg-primary: #1a1726; + --bg-secondary: #241f35; + --bg-tertiary: #2e2845; + --accent: #9b59b6; + --accent-hover: #8e44ad; + --accent-glow: rgba(155, 89, 182, .45); } -/* ──────────────────────────────────────────── - Theme: Neon - ──────────────────────────────────────────── */ -[data-theme="neon"] { - --bg-base: #08080e; - --bg-surface-0: #0e0e18; - --bg-surface-1: #141422; - --bg-surface-2: #1c1c30; - --bg-surface-3: #24243c; - - --text-primary: #f0f0ff; - --text-secondary: #8080b0; - --text-muted: #505078; - - --accent: #e040fb; - --accent-hover: #d020eb; - --accent-glow: rgba(224, 64, 251, 0.2); - --accent-subtle: rgba(224, 64, 251, 0.08); - - --border: rgba(224, 64, 251, 0.08); - --border-hover: rgba(224, 64, 251, 0.18); - --border-active: rgba(224, 64, 251, 0.45); - - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); - --shadow-glow: 0 0 24px var(--accent-glow); - - --grid-opacity: 1; - --grid-dot: rgba(224, 64, 251, 0.025); - - color-scheme: dark; +/* ── Theme: Forest ── */ +[data-theme="forest"] { + --bg-deep: #0f1a14; + --bg-primary: #142119; + --bg-secondary: #1c2e22; + --bg-tertiary: #253a2c; + --accent: #2ecc71; + --accent-hover: #27ae60; + --accent-glow: rgba(46, 204, 113, .4); } -/* ──────────────────────────────────────────── - Theme: Vapor (Retrowave) - ──────────────────────────────────────────── */ -[data-theme="vapor"] { - --bg-base: #140a22; - --bg-surface-0: #1a0e2e; - --bg-surface-1: #22143a; - --bg-surface-2: #2c1c48; - --bg-surface-3: #362456; - - --text-primary: #e8daf8; - --text-secondary: #8a6aaa; - --text-muted: #5a4070; - - --accent: #06d6a0; - --accent-hover: #05c090; - --accent-glow: rgba(6, 214, 160, 0.18); - --accent-subtle: rgba(6, 214, 160, 0.07); - - --border: rgba(6, 214, 160, 0.07); - --border-hover: rgba(6, 214, 160, 0.15); - --border-active: rgba(6, 214, 160, 0.4); - - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); - --shadow-glow: 0 0 24px var(--accent-glow); - - --grid-opacity: 1; - --grid-dot: rgba(6, 214, 160, 0.02); - - color-scheme: dark; +/* ── Theme: Sunset ── */ +[data-theme="sunset"] { + --bg-deep: #1a1210; + --bg-primary: #231815; + --bg-secondary: #2f201c; + --bg-tertiary: #3d2a24; + --accent: #e67e22; + --accent-hover: #d35400; + --accent-glow: rgba(230, 126, 34, .4); } -/* ──────────────────────────────────────────── - Theme: Matrix - ──────────────────────────────────────────── */ -[data-theme="matrix"] { - --bg-base: #050a05; - --bg-surface-0: #0a120a; - --bg-surface-1: #0f1a0f; - --bg-surface-2: #162216; - --bg-surface-3: #1e2e1e; - - --text-primary: #c0ecc0; - --text-secondary: #5a8a5a; - --text-muted: #2e5a2e; - - --accent: #22c55e; - --accent-hover: #16a34a; - --accent-glow: rgba(34, 197, 94, 0.18); - --accent-subtle: rgba(34, 197, 94, 0.06); - - --border: rgba(34, 197, 94, 0.07); - --border-hover: rgba(34, 197, 94, 0.15); - --border-active: rgba(34, 197, 94, 0.4); - - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7); - --shadow-glow: 0 0 24px var(--accent-glow); - - --grid-opacity: 1; - --grid-dot: rgba(34, 197, 94, 0.025); - - color-scheme: dark; +/* ── Theme: Ocean ── */ +[data-theme="ocean"] { + --bg-deep: #0a1628; + --bg-primary: #0f1e33; + --bg-secondary: #162a42; + --bg-tertiary: #1e3652; + --accent: #3498db; + --accent-hover: #2980b9; + --accent-glow: rgba(52, 152, 219, .4); } /* ──────────────────────────────────────────── @@ -199,15 +99,17 @@ padding: 0; } -body { - font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; - background: var(--bg-base); - color: var(--text-primary); +html, body { + height: 100%; + overflow: hidden; + background: var(--bg-deep); + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; + line-height: 1.4; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - overflow: hidden; - height: 100dvh; - width: 100vw; + transition: background .4s ease, color .4s ease; } button { @@ -223,20 +125,10 @@ input, select { color: inherit; } -::-webkit-scrollbar { - width: 6px; - height: 6px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - background: var(--text-muted); - border-radius: 3px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); -} +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #3f4147; } ::selection { background: var(--accent); @@ -244,281 +136,528 @@ input, select { } /* ──────────────────────────────────────────── - App Shell + App Layout ──────────────────────────────────────────── */ -.app-shell { +.app { display: flex; flex-direction: column; - height: 100dvh; - width: 100vw; - overflow: hidden; + height: 100vh; position: relative; } -/* Dot grid pattern overlay */ -.app-shell::after { - content: ''; - position: fixed; - inset: 0; - opacity: var(--grid-opacity); - background-image: radial-gradient( - circle, var(--grid-dot) 1px, transparent 1px - ); - background-size: 28px 28px; - pointer-events: none; - z-index: 0; -} - /* ──────────────────────────────────────────── - Header + Top Bar ──────────────────────────────────────────── */ -.header { - height: var(--header-h); - min-height: var(--header-h); +.topbar { display: flex; align-items: center; - gap: 16px; - padding: 0 24px; - background: var(--bg-surface-0); - border-bottom: 1px solid var(--border); - position: relative; - z-index: 20; -} - -.logo { - font-family: 'Syne', sans-serif; - font-weight: 800; - font-size: 22px; - letter-spacing: -0.5px; - background: linear-gradient(135deg, var(--accent), var(--text-primary)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + padding: 0 20px; + height: 52px; + background: var(--bg-secondary); + border-bottom: 1px solid rgba(0, 0, 0, .24); + z-index: 10; flex-shrink: 0; - user-select: none; + gap: 16px; + transition: background .4s ease; } -.header-search { - flex: 1; - max-width: 420px; - position: relative; +.topbar-left { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; } -.header-search .search-icon { - position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - font-size: 18px; - color: var(--text-muted); - pointer-events: none; -} - -.header-search input { - width: 100%; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 8px 36px 8px 38px; - font-size: 13.5px; - font-weight: 400; - color: var(--text-primary); - transition: all 0.2s ease; -} - -.header-search input::placeholder { - color: var(--text-muted); -} - -.header-search input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); - background: var(--bg-surface-1); -} - -.header-search .search-clear { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - width: 22px; - height: 22px; - border-radius: 50%; +.app-logo { + width: 28px; + height: 28px; + background: var(--accent); + border-radius: 8px; display: flex; align-items: center; justify-content: center; - color: var(--text-muted); - transition: all 0.15s; + transition: background .4s ease; } -.header-search .search-clear:hover { - background: var(--bg-surface-3); - color: var(--text-primary); +.app-title { + font-size: 16px; + font-weight: 700; + color: var(--white); + letter-spacing: -.02em; } -.header-meta { +/* ── Clock ── */ +.clock-wrap { + flex: 1; + display: flex; + justify-content: center; +} + +.clock { + font-size: 22px; + font-weight: 700; + color: var(--text-normal); + letter-spacing: .02em; + font-variant-numeric: tabular-nums; + opacity: .9; +} + +.clock-seconds { + font-size: 14px; + color: var(--text-faint); + font-weight: 500; +} + +.topbar-right { display: flex; align-items: center; - gap: 16px; - margin-left: auto; + gap: 6px; flex-shrink: 0; } -.sound-count { - font-size: 12px; +/* ── Channel Dropdown ── */ +.channel-dropdown { + position: relative; + flex-shrink: 0; +} + +.channel-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px 5px 10px; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-normal); + font-family: var(--font); + font-size: 13px; font-weight: 600; - color: var(--text-secondary); - font-variant-numeric: tabular-nums; + cursor: pointer; + transition: all var(--transition); white-space: nowrap; } -.sound-count strong { - color: var(--accent); - font-weight: 700; +.channel-btn:hover { + background: var(--bg-modifier-selected); + border-color: rgba(255, 255, 255, .12); } -/* ── Size Toggle (S / M / L) ── */ -.size-toggle { - display: inline-flex; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; +.channel-btn.open { + border-color: var(--accent); +} + +.channel-btn .cb-icon { + font-size: 16px; + color: var(--text-muted); +} + +.channel-btn .chevron { + font-size: 12px; + color: var(--text-faint); + transition: transform var(--transition); + margin-left: 2px; +} + +.channel-btn.open .chevron { + transform: rotate(180deg); +} + +.channel-status { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green); flex-shrink: 0; } -.size-opt { - padding: 4px 10px; - font-size: 11px; +.channel-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + background: var(--bg-deep); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: var(--radius); + box-shadow: var(--shadow-high); + padding: 6px; + z-index: 100; + animation: ctx-in 100ms ease-out; +} + +.channel-menu-header { + padding: 6px 8px 4px; + font-size: 10px; font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-faint); +} + +.channel-option { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 4px; + font-size: 13px; color: var(--text-muted); - transition: all 0.15s; - border-right: 1px solid var(--border); - line-height: 1; -} - -.size-opt:last-child { - border-right: none; -} - -.size-opt:hover { - color: var(--text-primary); - background: var(--bg-surface-3); -} - -.size-opt.active { - color: var(--accent); - background: var(--accent-subtle); -} - -/* Theme & Channel selects */ -.select-clean { - appearance: none; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 6px 28px 6px 10px; - font-size: 12.5px; - font-weight: 500; - color: var(--text-primary); cursor: pointer; - transition: all 0.15s; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237a7a90' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; + transition: all var(--transition); } -.select-clean:hover { - border-color: var(--border-hover); - background-color: var(--bg-surface-3); +.channel-option:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); } -.select-clean:focus { +.channel-option.active { + background: var(--accent); + color: var(--white); +} + +.channel-option .co-icon { + font-size: 16px; + opacity: .7; +} + +/* ── Connection Indicator ── */ +.connection { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + background: rgba(35, 165, 90, .12); + font-size: 12px; + color: var(--green); + font-weight: 600; +} + +.conn-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 6px rgba(35, 165, 90, .6); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { box-shadow: 0 0 6px rgba(35, 165, 90, .5); } + 50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); } +} + +/* ── Admin Icon Button ── */ +.admin-btn-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-faint); + transition: all var(--transition); + font-size: 18px; +} + +.admin-btn-icon:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.admin-btn-icon.active { + color: var(--accent); +} + +/* ──────────────────────────────────────────── + Toolbar + ──────────────────────────────────────────── */ +.toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: var(--bg-primary); + border-bottom: 1px solid rgba(0, 0, 0, .12); + flex-shrink: 0; + flex-wrap: wrap; + transition: background .4s ease; +} + +/* ── Category Tabs ── */ +.cat-tabs { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.cat-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-muted); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.cat-tab:hover { + background: var(--bg-modifier-selected); + color: var(--text-normal); +} + +.cat-tab.active { + background: var(--accent); + color: var(--white); +} + +.tab-count { + font-size: 10px; + font-weight: 700; + background: rgba(255, 255, 255, .15); + padding: 0 6px; + border-radius: 8px; + line-height: 1.6; +} + +/* ── Search ── */ +.search-wrap { + position: relative; + flex: 1; + max-width: 280px; + min-width: 140px; +} + +.search-wrap .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 15px; + color: var(--text-faint); + pointer-events: none; +} + +.search-input { + width: 100%; + height: 32px; + padding: 0 28px 0 32px; + border: 1px solid rgba(255, 255, 255, .06); + border-radius: 20px; + background: var(--bg-secondary); + color: var(--text-normal); + font-family: var(--font); + font-size: 13px; outline: none; + transition: all var(--transition); +} + +.search-input::placeholder { + color: var(--text-faint); +} + +.search-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); } -.select-clean option { - background: var(--bg-surface-1); - color: var(--text-primary); -} - -/* ──────────────────────────────────────────── - Tab Bar - ──────────────────────────────────────────── */ -.tab-bar { - height: var(--tabs-h); - min-height: var(--tabs-h); +.search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + border-radius: 50%; display: flex; - align-items: stretch; - gap: 0; - padding: 0 24px; - background: var(--bg-surface-0); - border-bottom: 1px solid var(--border); - position: relative; - z-index: 15; + align-items: center; + justify-content: center; + color: var(--text-faint); + transition: all var(--transition); } -.tab-btn { - position: relative; - padding: 0 18px; - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - transition: color 0.2s; +.search-clear:hover { + background: var(--bg-tertiary); + color: var(--text-normal); +} + +.toolbar-spacer { + flex: 1; +} + +/* ── Toolbar Buttons ── */ +.tb-btn { display: flex; align-items: center; gap: 6px; + padding: 6px 12px; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-muted); + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); white-space: nowrap; } -.tab-btn:hover { - color: var(--text-primary); +.tb-btn:hover { + background: var(--bg-modifier-selected); + color: var(--text-normal); + border-color: rgba(255, 255, 255, .12); } -.tab-btn.active { +.tb-btn .tb-icon { + font-size: 15px; +} + +.tb-btn.random { + border-color: rgba(88, 101, 242, .3); color: var(--accent); } -.tab-btn.active::after { - content: ''; - position: absolute; - bottom: 0; - left: 12px; - right: 12px; - height: 2px; +.tb-btn.random:hover { background: var(--accent); - border-radius: 2px 2px 0 0; + color: var(--white); + border-color: var(--accent); } -.tab-badge { - font-size: 10.5px; - font-weight: 700; - background: var(--accent-subtle); - color: var(--accent); - padding: 1px 6px; - border-radius: var(--radius-pill); - font-variant-numeric: tabular-nums; +.tb-btn.party { + border-color: rgba(240, 178, 50, .3); + color: var(--yellow); } -/* ──────────────────────────────────────────── - Category Filter Strip - ──────────────────────────────────────────── */ -.category-strip { - min-height: var(--cats-h); +.tb-btn.party:hover { + background: var(--yellow); + color: #1a1b1e; + border-color: var(--yellow); +} + +.tb-btn.party.active { + background: var(--yellow); + color: #1a1b1e; + border-color: var(--yellow); + animation: party-btn 600ms ease-in-out infinite alternate; +} + +@keyframes party-btn { + from { box-shadow: 0 0 8px rgba(240, 178, 50, .4); } + to { box-shadow: 0 0 20px rgba(240, 178, 50, .7); } +} + +.tb-btn.stop { + border-color: rgba(242, 63, 66, .3); + color: var(--red); +} + +.tb-btn.stop:hover { + background: var(--red); + color: var(--white); + border-color: var(--red); +} + +/* ── Size Slider ── */ +.size-control { display: flex; align-items: center; gap: 6px; - padding: 0 24px; - background: var(--bg-surface-0); - border-bottom: 1px solid var(--border); + padding: 4px 10px; + border-radius: 20px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.size-control .sc-icon { + font-size: 14px; + color: var(--text-faint); +} + +.size-slider { + -webkit-appearance: none; + appearance: none; + width: 70px; + height: 3px; + border-radius: 2px; + background: var(--bg-modifier-selected); + outline: none; + cursor: pointer; +} + +.size-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + transition: transform var(--transition); +} + +.size-slider::-webkit-slider-thumb:hover { + transform: scale(1.3); +} + +.size-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: none; + cursor: pointer; +} + +/* ── Theme Selector ── */ +.theme-selector { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 20px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.theme-dot { + width: 16px; + height: 16px; + border-radius: 50%; + cursor: pointer; + transition: all var(--transition); + border: 2px solid transparent; +} + +.theme-dot:hover { + transform: scale(1.2); +} + +.theme-dot.active { + border-color: var(--white); + box-shadow: 0 0 6px rgba(255, 255, 255, .3); +} + +/* ──────────────────────────────────────────── + Category / Folder Strip + ──────────────────────────────────────────── */ +.category-strip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + background: var(--bg-primary); + border-bottom: 1px solid rgba(0, 0, 0, .12); overflow-x: auto; - overflow-y: hidden; - position: relative; - z-index: 14; + flex-shrink: 0; scrollbar-width: none; + transition: background .4s ease; } .category-strip::-webkit-scrollbar { @@ -529,28 +668,27 @@ input, select { display: inline-flex; align-items: center; gap: 6px; - padding: 5px 12px; - border-radius: var(--radius-pill); + padding: 4px 12px; + border-radius: 20px; font-size: 12px; font-weight: 600; - color: var(--text-secondary); - background: var(--bg-surface-2); - border: 1px solid var(--border); + color: var(--text-muted); + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .06); white-space: nowrap; - transition: all 0.15s ease; + cursor: pointer; + transition: all var(--transition); flex-shrink: 0; } .cat-chip:hover { - border-color: var(--border-hover); - color: var(--text-primary); - background: var(--bg-surface-3); + border-color: rgba(255, 255, 255, .12); + color: var(--text-normal); + background: var(--bg-tertiary); } .cat-chip.active { - background: var(--accent-subtle); - border-color: var(--accent); - color: var(--accent); + background: rgba(88, 101, 242, .1); } .cat-dot { @@ -560,541 +698,560 @@ input, select { flex-shrink: 0; } +.cat-count { + font-size: 10px; + font-weight: 700; + opacity: .5; +} + /* ──────────────────────────────────────────── - Sounds Area & Grid + Main Grid Area ──────────────────────────────────────────── */ -.sounds-area { +.main { flex: 1; overflow-y: auto; - overflow-x: hidden; + padding: 16px 20px; + background: var(--bg-primary); + transition: background .4s ease; +} + +.sound-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--card-size), 1fr)); + gap: 8px; +} + +/* ──────────────────────────────────────────── + Sound Card + ──────────────────────────────────────────── */ +.sound-card { position: relative; - z-index: 1; -} - -.sounds-grid { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 16px 24px; - padding-bottom: calc(var(--control-h) + 24px); - align-content: flex-start; -} - -.sounds-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 80px 24px; - color: var(--text-muted); - text-align: center; - width: 100%; -} - -.sounds-empty .material-icons { - font-size: 48px; - margin-bottom: 12px; - opacity: 0.4; -} - -.sounds-empty p { - font-size: 14px; - font-weight: 500; -} - -/* ── Sound Button (Compact Pill) ── */ -.sound-btn { - display: inline-flex; - align-items: center; - gap: 0; - height: 36px; - padding: 0 12px 0 0; - border-radius: var(--radius-sm); - background: var(--bg-surface-1); - border: 1px solid var(--border); - font-size: 12.5px; - font-weight: 500; - color: var(--text-primary); + gap: 3px; + padding: 12px 6px 8px; + background: var(--bg-secondary); + border-radius: var(--radius-lg); cursor: pointer; - transition: all 0.12s ease; - position: relative; + transition: all var(--transition); + border: 2px solid transparent; + user-select: none; overflow: hidden; - max-width: 220px; - flex-shrink: 0; + aspect-ratio: 1; + opacity: 0; + animation: card-enter 350ms ease-out forwards; } -.sound-btn .cat-bar { - width: 3px; - height: 100%; - flex-shrink: 0; - border-radius: var(--radius-sm) 0 0 var(--radius-sm); +.sound-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0; + transition: opacity var(--transition); + background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%); + pointer-events: none; } -.sound-btn .sound-label { - padding: 0 10px; - white-space: nowrap; +.sound-card:hover { + background: var(--bg-tertiary); + transform: translateY(-3px); + box-shadow: var(--shadow-med), 0 0 20px var(--accent-glow); + border-color: rgba(88, 101, 242, .2); +} + +.sound-card:hover::before { + opacity: 1; +} + +.sound-card:active { + transform: translateY(0); + transition-duration: 50ms; +} + +.sound-card.playing { + border-color: var(--accent); + animation: card-enter 350ms ease-out forwards, playing-glow 1.2s ease-in-out infinite alternate; +} + +@keyframes playing-glow { + from { box-shadow: 0 0 4px var(--accent-glow); } + to { box-shadow: 0 0 16px var(--accent-glow); } +} + +@keyframes card-enter { + from { opacity: 0; transform: translateY(10px) scale(.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ── Ripple Effect ── */ +.ripple { + position: absolute; + border-radius: 50%; + background: rgba(88, 101, 242, .3); + transform: scale(0); + animation: ripple-expand 500ms ease-out forwards; + pointer-events: none; +} + +@keyframes ripple-expand { + to { transform: scale(3); opacity: 0; } +} + +/* ── Sound Card Content ── */ +.sound-emoji { + font-size: var(--card-emoji); + font-weight: 800; + line-height: 1; + z-index: 1; + transition: transform var(--transition); + opacity: .7; + font-family: 'Syne', 'DM Sans', sans-serif; +} + +.sound-card:hover .sound-emoji { + transform: scale(1.15); + opacity: 1; +} + +.sound-card.playing .sound-emoji { + animation: emoji-bounce 400ms ease; + opacity: 1; +} + +@keyframes emoji-bounce { + 0%, 100% { transform: scale(1); } + 40% { transform: scale(1.3); } + 70% { transform: scale(.95); } +} + +.sound-name { + font-size: var(--card-font); + font-weight: 600; + text-align: center; + color: var(--text-normal); + z-index: 1; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; - line-height: 36px; + white-space: nowrap; + padding: 0 4px; } -.sound-btn:hover { - background: var(--bg-surface-2); - border-color: var(--border-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-md); +.sound-duration { + font-size: 9px; + color: var(--text-faint); + z-index: 1; + font-weight: 500; } -.sound-btn:active { - transform: scale(0.96); - box-shadow: none; - transition-duration: 0.05s; -} - -.sound-btn .fav-star { +/* ── Favorite Star ── */ +.fav-star { position: absolute; - right: 3px; - top: 50%; - transform: translateY(-50%); - font-size: 14px; + top: 4px; + right: 4px; opacity: 0; - color: var(--text-muted); - transition: all 0.15s; + transition: all var(--transition); + cursor: pointer; + z-index: 2; + color: var(--text-faint); padding: 2px; line-height: 1; } -.sound-btn:hover .fav-star { - opacity: 0.6; +.fav-star .fav-icon { + font-size: 14px; } -.sound-btn .fav-star.is-fav { - opacity: 1; - color: var(--warning); +.sound-card:hover .fav-star { + opacity: .6; } -.sound-btn:hover .fav-star.is-fav { - opacity: 1; +.fav-star:hover { + opacity: 1 !important; + color: var(--yellow); + transform: scale(1.2); } -/* Playing animation */ -.sound-btn.is-playing { - border-color: var(--accent); - box-shadow: var(--shadow-glow); +.fav-star.active { + opacity: 1 !important; + color: var(--yellow); } -.sound-btn.is-playing::after { - content: ''; +/* ── "New" Badge ── */ +.new-badge { position: absolute; - inset: 0; - background: var(--accent-glow); - animation: pulse-bg 1s ease infinite; - pointer-events: none; -} - -/* Badge indicators */ -.sound-btn .badge-dot { - position: absolute; - top: 3px; - right: 3px; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); -} - -.sound-btn .badge-dot.new { - background: var(--success); -} - -.sound-btn .badge-dot.top { - background: var(--warning); -} - -/* ── Button Size Variants (S / M / L) ── */ -/* S = default (36px) */ - -[data-btn-size="M"] .sound-btn { - height: 46px; - font-size: 13.5px; - max-width: 260px; - padding: 0 16px 0 0; -} - -[data-btn-size="M"] .sound-btn .sound-label { - line-height: 46px; - padding: 0 12px; -} - -[data-btn-size="M"] .sound-btn .cat-bar { - width: 4px; -} - -[data-btn-size="M"] .sound-btn .fav-star { - right: 4px; -} - -[data-btn-size="M"] .sound-btn .fav-star .material-icons { - font-size: 16px; -} - -[data-btn-size="M"] .sound-btn .badge-dot { - width: 7px; - height: 7px; top: 4px; - right: 4px; + left: 4px; + font-size: 8px; + font-weight: 700; + background: var(--green); + color: white; + padding: 1px 5px; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: .03em; + z-index: 2; } -[data-btn-size="L"] .sound-btn { - height: 58px; - font-size: 14.5px; - max-width: 300px; - padding: 0 20px 0 0; +/* ── Playing Wave Indicator ── */ +.playing-indicator { + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + display: none; + gap: 2px; + align-items: flex-end; + height: 10px; } -[data-btn-size="L"] .sound-btn .sound-label { - line-height: 58px; - padding: 0 14px; +.sound-card.playing .playing-indicator { + display: flex; } -[data-btn-size="L"] .sound-btn .cat-bar { - width: 5px; +.wave-bar { + width: 2px; + background: var(--accent); + border-radius: 1px; + animation: wave 600ms ease-in-out infinite alternate; } -[data-btn-size="L"] .sound-btn .fav-star { - right: 6px; -} +.wave-bar:nth-child(1) { height: 3px; animation-delay: 0ms; } +.wave-bar:nth-child(2) { height: 7px; animation-delay: 150ms; } +.wave-bar:nth-child(3) { height: 5px; animation-delay: 300ms; } +.wave-bar:nth-child(4) { height: 9px; animation-delay: 100ms; } -[data-btn-size="L"] .sound-btn .fav-star .material-icons { - font-size: 18px; -} - -[data-btn-size="L"] .sound-btn .badge-dot { - width: 8px; - height: 8px; - top: 5px; - right: 5px; +@keyframes wave { + from { height: 2px; } + to { height: 10px; } } /* ──────────────────────────────────────────── - Control Bar (Bottom) + Empty State ──────────────────────────────────────────── */ -.control-bar { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: var(--control-h); - background: var(--bg-surface-0); - border-top: 1px solid var(--border); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - display: flex; +.empty-state { + display: none; + flex-direction: column; align-items: center; - padding: 0 24px; - gap: 12px; - z-index: 30; + justify-content: center; + gap: 10px; + padding: 60px 20px; + text-align: center; } -.ctrl-section { +.empty-state.visible { + display: flex; +} + +.empty-emoji { + font-size: 42px; +} + +.empty-title { + font-size: 15px; + font-weight: 700; + color: var(--text-normal); +} + +.empty-desc { + font-size: 13px; + color: var(--text-muted); + max-width: 260px; +} + +/* ──────────────────────────────────────────── + Bottom Bar + ──────────────────────────────────────────── */ +.bottombar { + display: flex; + align-items: center; + gap: 14px; + padding: 0 20px; + height: 48px; + background: var(--bg-secondary); + border-top: 1px solid rgba(0, 0, 0, .24); + z-index: 10; + flex-shrink: 0; + transition: background .4s ease; +} + +.now-playing { display: flex; align-items: center; gap: 8px; -} - -.ctrl-section.left { - flex: 1; - min-width: 0; -} - -.ctrl-section.center { - flex-shrink: 0; -} - -.ctrl-section.right { - flex: 1; - justify-content: flex-end; - min-width: 0; -} - -/* Channel select in control bar */ -.channel-wrap { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.channel-wrap .material-icons { - font-size: 18px; + font-size: 13px; color: var(--text-muted); - flex-shrink: 0; + min-width: 0; + flex: 1; } -.channel-select { - appearance: none; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 6px 28px 6px 10px; +.np-label { + color: var(--text-faint); font-size: 12px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - min-width: 160px; - max-width: 280px; - transition: all 0.15s; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237a7a90' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; -} - -.channel-select:hover { - border-color: var(--border-hover); -} - -.channel-select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-glow); -} - -.channel-select option { - background: var(--bg-surface-1); -} - -/* ── Control Buttons ── */ -.ctrl-btn { - height: 38px; - padding: 0 16px; - border-radius: var(--radius-pill); - font-size: 12.5px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.15s ease; white-space: nowrap; } -.ctrl-btn .material-icons { - font-size: 18px; +.np-name { + color: var(--accent); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -/* Stop */ -.ctrl-btn.stop { - background: var(--danger-glow); - color: var(--danger); -} -.ctrl-btn.stop:hover { - background: var(--danger); - color: white; - box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); +.np-waves { + display: none; + gap: 1.5px; + align-items: flex-end; + height: 14px; } -/* Random / Shuffle */ -.ctrl-btn.shuffle { +.np-waves.active { + display: flex; +} + +.np-wave-bar { + width: 2.5px; background: var(--accent); - color: white; -} -.ctrl-btn.shuffle:hover { - background: var(--accent-hover); - box-shadow: 0 4px 16px var(--accent-glow); - transform: scale(1.03); + border-radius: 1px; + animation: wave 500ms ease-in-out infinite alternate; } -/* Party Mode */ -.ctrl-btn.party { - background: var(--bg-surface-2); - color: var(--text-secondary); - border: 1px solid var(--border); -} -.ctrl-btn.party:hover { - border-color: var(--border-hover); - color: var(--text-primary); - background: var(--bg-surface-3); -} - -.ctrl-btn.party.active { - background: linear-gradient(135deg, #ec4899, #8b5cf6, #3b82f6, #06b6d4); - background-size: 300% 300%; - animation: party-gradient 4s ease infinite; - color: white; - border: none; - box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4); -} - -.ctrl-btn.party.active:hover { - transform: scale(1.03); -} +.np-wave-bar:nth-child(1) { height: 4px; animation-delay: 0ms; } +.np-wave-bar:nth-child(2) { height: 9px; animation-delay: 120ms; } +.np-wave-bar:nth-child(3) { height: 6px; animation-delay: 240ms; } +.np-wave-bar:nth-child(4) { height: 11px; animation-delay: 80ms; } /* ── Volume ── */ -.volume-wrap { +.volume-section { display: flex; align-items: center; gap: 8px; - width: 150px; - flex-shrink: 0; + margin-left: auto; } -.volume-wrap .material-icons { - font-size: 16px; +.volume-icon { + font-size: 18px; color: var(--text-muted); cursor: pointer; - flex-shrink: 0; + transition: color var(--transition); } -.volume-wrap .material-icons:hover { - color: var(--text-secondary); +.volume-icon:hover { + color: var(--text-normal); } .volume-slider { -webkit-appearance: none; appearance: none; - width: 100%; - height: 4px; - background: var(--bg-surface-3); + width: 90px; + height: 3px; border-radius: 2px; + background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--vol, 80%), var(--bg-tertiary) var(--vol, 80%)); outline: none; - position: relative; - background-image: linear-gradient(var(--accent), var(--accent)); - background-size: var(--fill, 100%) 100%; - background-repeat: no-repeat; + cursor: pointer; } .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; - width: 14px; - height: 14px; + width: 12px; + height: 12px; border-radius: 50%; - background: var(--text-primary); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + background: var(--white); + box-shadow: 0 1px 4px rgba(0, 0, 0, .3); cursor: pointer; - transition: transform 0.1s; -} - -.volume-slider::-webkit-slider-thumb:hover { - transform: scale(1.2); } .volume-slider::-moz-range-thumb { - width: 14px; - height: 14px; + width: 12px; + height: 12px; border-radius: 50%; - background: var(--text-primary); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); - cursor: pointer; + background: var(--white); + box-shadow: 0 1px 4px rgba(0, 0, 0, .3); border: none; + cursor: pointer; } .volume-pct { font-size: 11px; - font-weight: 600; - color: var(--text-muted); - min-width: 30px; + color: var(--text-faint); + min-width: 28px; text-align: right; font-variant-numeric: tabular-nums; } -/* ── Now Playing ── */ -.now-playing { - font-size: 11px; - font-weight: 500; - color: var(--text-muted); - max-width: 180px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +/* ──────────────────────────────────────────── + Party Mode Overlay + ──────────────────────────────────────────── */ +.party-overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 50; + opacity: 0; + transition: opacity .3s ease; } -.now-playing span { - color: var(--accent); +.party-overlay.active { + opacity: 1; + animation: party-hue 2s linear infinite; +} + +.party-overlay::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(45deg, + rgba(255, 0, 0, .04), + rgba(0, 255, 0, .04), + rgba(0, 0, 255, .04), + rgba(255, 255, 0, .04) + ); + background-size: 400% 400%; + animation: party-grad 3s ease infinite; +} + +@keyframes party-grad { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes party-hue { + to { filter: hue-rotate(360deg); } } /* ──────────────────────────────────────────── - Notifications / Toast + Context Menu + ──────────────────────────────────────────── */ +.ctx-menu { + position: fixed; + min-width: 160px; + background: var(--bg-deep); + border: 1px solid rgba(255, 255, 255, .06); + border-radius: var(--radius); + box-shadow: var(--shadow-high); + padding: 4px; + z-index: 1000; + animation: ctx-in 100ms ease-out; +} + +@keyframes ctx-in { + from { opacity: 0; transform: scale(.96) translateY(-4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.ctx-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 4px; + font-size: 13px; + color: var(--text-normal); + cursor: pointer; + transition: all var(--transition); +} + +.ctx-item:hover { + background: var(--accent); + color: var(--white); +} + +.ctx-item.danger { + color: var(--red); +} + +.ctx-item.danger:hover { + background: var(--red); + color: var(--white); +} + +.ctx-item .ctx-icon { + font-size: 15px; +} + +.ctx-sep { + height: 1px; + background: rgba(255, 255, 255, .06); + margin: 3px 8px; +} + +/* ──────────────────────────────────────────── + Toast Notification ──────────────────────────────────────────── */ .toast { position: fixed; - bottom: calc(var(--control-h) + 16px); + bottom: 64px; left: 50%; transform: translateX(-50%); padding: 10px 20px; - border-radius: var(--radius-pill); + border-radius: 20px; font-size: 13px; font-weight: 600; z-index: 100; display: flex; align-items: center; gap: 8px; - box-shadow: var(--shadow-lg); + box-shadow: var(--shadow-high); animation: toast-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); pointer-events: none; } -.toast .material-icons { +.toast .toast-icon { font-size: 16px; } .toast.error { - background: var(--danger); + background: var(--red); color: white; } .toast.info { - background: var(--success); + background: var(--green); color: white; } +@keyframes toast-in { + from { transform: translate(-50%, 16px); opacity: 0; } + to { transform: translate(-50%, 0); opacity: 1; } +} + /* ──────────────────────────────────────────── - Admin Panel (Overlay) + Admin Panel Overlay ──────────────────────────────────────────── */ -.admin-toggle { - width: 32px; - height: 32px; - border-radius: var(--radius-sm); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); - transition: all 0.15s; -} - -.admin-toggle:hover { - background: var(--bg-surface-2); - color: var(--text-primary); -} - -.admin-toggle.is-admin { - color: var(--accent); -} - .admin-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, .6); backdrop-filter: blur(8px); - z-index: 50; + -webkit-backdrop-filter: blur(8px); + z-index: 60; display: flex; align-items: center; justify-content: center; - animation: fade-in 0.2s ease; + animation: fade-in 200ms ease; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } } .admin-panel { - background: var(--bg-surface-1); - border: 1px solid var(--border); + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .08); border-radius: var(--radius-lg); padding: 28px; width: 90%; - max-width: 480px; - max-height: 80vh; - overflow-y: auto; - box-shadow: var(--shadow-lg); + max-width: 400px; + box-shadow: var(--shadow-high); } .admin-panel h3 { - font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 700; margin-bottom: 20px; @@ -1103,20 +1260,20 @@ input, select { justify-content: space-between; } -.admin-panel .admin-close { +.admin-close { width: 28px; height: 28px; - border-radius: var(--radius-sm); + border-radius: 6px; display: flex; align-items: center; justify-content: center; - color: var(--text-secondary); - transition: all 0.15s; + color: var(--text-muted); + transition: all var(--transition); } -.admin-panel .admin-close:hover { - background: var(--bg-surface-3); - color: var(--text-primary); +.admin-close:hover { + background: var(--bg-tertiary); + color: var(--text-normal); } .admin-field { @@ -1127,20 +1284,22 @@ input, select { display: block; font-size: 12px; font-weight: 600; - color: var(--text-secondary); + color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: .5px; } .admin-field input { width: 100%; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 8px; padding: 10px 12px; font-size: 14px; - transition: all 0.15s; + color: var(--text-normal); + font-family: var(--font); + transition: all var(--transition); } .admin-field input:focus { @@ -1149,242 +1308,110 @@ input, select { box-shadow: 0 0 0 2px var(--accent-glow); } -.admin-btn { +.admin-btn-action { padding: 10px 20px; - border-radius: var(--radius-sm); + border-radius: 8px; font-size: 13px; font-weight: 600; - transition: all 0.15s; + font-family: var(--font); + cursor: pointer; + transition: all var(--transition); } -.admin-btn.primary { +.admin-btn-action.primary { background: var(--accent); color: white; + border: none; } -.admin-btn.primary:hover { + +.admin-btn-action.primary:hover { background: var(--accent-hover); } -.admin-btn.outline { +.admin-btn-action.outline { background: transparent; - border: 1px solid var(--border); - color: var(--text-secondary); -} -.admin-btn.outline:hover { - border-color: var(--border-hover); - color: var(--text-primary); + border: 1px solid rgba(255, 255, 255, .08); + color: var(--text-muted); } -.admin-btn.danger { - background: var(--danger-glow); - color: var(--danger); -} -.admin-btn.danger:hover { - background: var(--danger); - color: white; -} - -/* ──────────────────────────────────────────── - Party Mode Global Effects - ──────────────────────────────────────────── */ -.app-shell.party-active .header { - border-bottom-color: transparent; - background: linear-gradient(90deg, - rgba(236, 72, 153, 0.08), - rgba(139, 92, 246, 0.08), - rgba(59, 130, 246, 0.08), - rgba(6, 182, 212, 0.08) - ); - background-size: 400% 100%; - animation: party-gradient 6s ease infinite; -} - -.app-shell.party-active .control-bar { - border-top-color: transparent; - box-shadow: 0 -2px 20px rgba(139, 92, 246, 0.15); -} - -/* ──────────────────────────────────────────── - Animations & Keyframes - ──────────────────────────────────────────── */ -@keyframes party-gradient { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -@keyframes pulse-bg { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 0.6; } -} - -@keyframes toast-in { - from { - transform: translate(-50%, 16px); - opacity: 0; - } - to { - transform: translate(-50%, 0); - opacity: 1; - } -} - -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Staggered entrance for sound buttons */ -.sounds-grid .sound-btn { - animation: sound-enter 0.2s ease both; -} - -@keyframes sound-enter { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } +.admin-btn-action.outline:hover { + border-color: rgba(255, 255, 255, .12); + color: var(--text-normal); } /* ──────────────────────────────────────────── Responsive ──────────────────────────────────────────── */ -@media (max-width: 768px) { - .header { - padding: 0 16px; - gap: 10px; - height: auto; - min-height: 56px; - flex-wrap: wrap; - padding-top: 10px; - padding-bottom: 10px; +@media (max-width: 700px) { + .toolbar { + gap: 6px; + padding: 8px 12px; } - .logo { - font-size: 18px; - } - - .header-search { - order: 10; - max-width: 100%; - flex-basis: 100%; - } - - .header-meta { - gap: 8px; - } - - .sound-count { - display: none; - } - - .tab-bar { - padding: 0 16px; + .cat-tabs { overflow-x: auto; scrollbar-width: none; } - .tab-bar::-webkit-scrollbar { + .cat-tabs::-webkit-scrollbar { display: none; } - .category-strip { - padding: 0 16px; + .search-wrap { + max-width: 100%; + min-width: 100%; + order: -1; } - .sounds-grid { - padding: 12px 16px; - gap: 5px; + .size-control, + .theme-selector { + display: none; } - .sound-btn { - font-size: 11.5px; - height: 32px; - max-width: 180px; + .main { + padding: 12px; } - [data-btn-size="M"] .sound-btn { - height: 40px; - font-size: 12.5px; - max-width: 220px; - } - [data-btn-size="M"] .sound-btn .sound-label { - line-height: 40px; - } - - [data-btn-size="L"] .sound-btn { - height: 50px; - font-size: 13.5px; - max-width: 260px; - } - [data-btn-size="L"] .sound-btn .sound-label { - line-height: 50px; - } - - .control-bar { + .topbar { padding: 0 12px; - height: auto; - min-height: var(--control-h); - flex-wrap: wrap; - padding-top: 10px; - padding-bottom: 10px; gap: 8px; } - .ctrl-section.left { - order: 1; - flex-basis: 100%; + .channel-label { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; } - .ctrl-section.center { - order: 2; + .clock { + font-size: 16px; } - .ctrl-section.right { - order: 3; + .clock-seconds { + font-size: 11px; } - .volume-wrap { - width: 120px; + .bottombar { + padding: 0 12px; } - .channel-select { - min-width: 120px; - } - - .sounds-grid { - padding-bottom: calc(140px + 24px); - } - - .toast { - bottom: 150px; + .tb-btn span:not(.tb-icon) { + display: none; } } @media (max-width: 480px) { - .ctrl-btn span:not(.material-icons) { + .connection { display: none; } - .ctrl-btn { - padding: 0 10px; - } - - .volume-wrap { - width: 100px; - } - - .now-playing { + .app-title { display: none; } + + .toolbar .tb-btn { + padding: 6px 8px; + } } /* ──────────────────────────────────────────── @@ -1401,17 +1428,3 @@ input, select { white-space: nowrap; border-width: 0; } - -/* Vapor theme gradient overlay */ -[data-theme="vapor"] .app-shell::before { - content: ''; - position: fixed; - inset: 0; - background: linear-gradient(180deg, - rgba(120, 40, 200, 0.06) 0%, - transparent 40%, - rgba(6, 214, 160, 0.04) 100% - ); - pointer-events: none; - z-index: 0; -} From 5f0b06550e676d25e21fb09ec5eb6ce655cfd8c6 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 15:28:53 +0100 Subject: [PATCH 10/22] feat(web): show initial letter only on first sound of each group Instead of displaying the letter (A, B, C...) on every sound card, only show it on the first card that starts with that letter. Makes the grid much cleaner and easier to scan. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 440fd90..e281b3a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -282,6 +282,16 @@ export default function App() { return m; }, [visibleFolders]); + const firstOfInitial = useMemo(() => { + const seen = new Set(); + const result = new Set(); + displaySounds.forEach((s, idx) => { + const ch = s.name.charAt(0).toUpperCase(); + if (!seen.has(ch)) { seen.add(ch); result.add(idx); } + }); + return result; + }, [displaySounds]); + const channelsByGuild = useMemo(() => { const groups: Record = {}; channels.forEach(c => { @@ -503,12 +513,13 @@ export default function App() { 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 (
{ const card = e.currentTarget; @@ -541,7 +552,7 @@ export default function App() { > {isFav ? 'star' : 'star_border'} - {initial} + {showInitial && {initial}} {s.name} {s.folder && {s.folder}}
From 4661c366fb472e3e6439ba94989ac2df6cff230f Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 15:37:40 +0100 Subject: [PATCH 11/22] UI: Volume-Regler in Toolbar verschoben, Spielt-Anzeige persistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Volume-Control von Bottom-Bar in Toolbar verschoben (links von Random) - Pill-förmiges Design passend zu Size-Slider - "Spielt" Anzeige bleibt bis Stop oder neuer Sound sichtbar - Anfangsbuchstaben nur beim ersten Sound jeder Gruppe Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 57 +++++++++++++++++++++++----------------------- web/src/styles.css | 43 +++++++++++++++++++--------------- 2 files changed, 54 insertions(+), 46 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index e281b3a..c88c4c8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -216,12 +216,12 @@ export default function App() { try { await playSound(s.name, guildId, channelId, volume, s.relativePath); setLastPlayed(s.name); - setTimeout(() => setLastPlayed(''), 4000); } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } async function handleStop() { if (!selected) return; + setLastPlayed(''); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } } @@ -421,6 +421,34 @@ export default function App() {
+
+ { + 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'} + + { + const v = parseFloat(e.target.value); + setVolume(v); + if (guildId) try { await setVolumeLive(guildId, v); } catch { } + }} + style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} + /> + {Math.round(volume * 100)}% +
+
-
- { - 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'} - - { - const v = parseFloat(e.target.value); - setVolume(v); - if (guildId) try { await setVolumeLive(guildId, v); } catch { } - }} - style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} - /> - {Math.round(volume * 100)}% -
{/* ═══ CONTEXT MENU ═══ */} diff --git a/web/src/styles.css b/web/src/styles.css index 92185be..b715f20 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1025,57 +1025,64 @@ input, select { .np-wave-bar:nth-child(3) { height: 6px; animation-delay: 240ms; } .np-wave-bar:nth-child(4) { height: 11px; animation-delay: 80ms; } -/* ── Volume ── */ -.volume-section { +/* ── Volume Control (Toolbar) ── */ +.volume-control { display: flex; align-items: center; - gap: 8px; - margin-left: auto; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .06); } -.volume-icon { - font-size: 18px; - color: var(--text-muted); +.vol-icon { + font-size: 16px; + color: var(--text-faint); cursor: pointer; transition: color var(--transition); + user-select: none; } -.volume-icon:hover { +.vol-icon:hover { color: var(--text-normal); } -.volume-slider { +.vol-slider { -webkit-appearance: none; appearance: none; - width: 90px; + width: 80px; height: 3px; border-radius: 2px; - background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--vol, 80%), var(--bg-tertiary) var(--vol, 80%)); + background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--vol, 80%), var(--bg-modifier-selected) var(--vol, 80%)); outline: none; cursor: pointer; } -.volume-slider::-webkit-slider-thumb { +.vol-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; - background: var(--white); - box-shadow: 0 1px 4px rgba(0, 0, 0, .3); + background: var(--accent); cursor: pointer; + transition: transform var(--transition); } -.volume-slider::-moz-range-thumb { +.vol-slider::-webkit-slider-thumb:hover { + transform: scale(1.3); +} + +.vol-slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; - background: var(--white); - box-shadow: 0 1px 4px rgba(0, 0, 0, .3); + background: var(--accent); border: none; cursor: pointer; } -.volume-pct { +.vol-pct { font-size: 11px; color: var(--text-faint); min-width: 28px; From f90401a009051d1c7c639423b5d473aab8bdfca3 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 16:00:22 +0100 Subject: [PATCH 12/22] Feat: Now-Playing serverseitig syncen + in Topbar verschieben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - nowPlaying Map trackt aktuell gespielten Sound pro Guild - SSE broadcast { type: 'nowplaying' } bei play und stop - nowplaying im SSE-Snapshot für neue Clients - playFilePath Helper broadcastet ebenfalls (Party Mode) Frontend: - SSE-Handler für nowplaying Events (sync über alle Clients) - Now-Playing als Pill-Badge in der Topbar (rechts, neben Channel) - Bottombar komplett entfernt - Fade-in Animation und accent-farbige Pill - --accent-rgb CSS Variable für alle Themes Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 14 ++++++++++- web/src/App.tsx | 29 ++++++++++++--------- web/src/styles.css | 61 +++++++++++++++++++++------------------------ 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index e962928..174ea64 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -164,6 +164,8 @@ const guildAudioState = new Map(); // Partymode: serverseitige Steuerung (global pro Guild) const partyTimers = new Map(); const partyActive = new Set(); +// Now-Playing: aktuell gespielter Sound pro Guild +const nowPlaying = new Map(); // SSE-Klienten für Broadcasts (z.B. Partymode Status) const sseClients = new Set(); function sseBroadcast(payload: any) { @@ -265,6 +267,10 @@ async function playFilePath(guildId: string, channelId: string, filePath: string state.player.play(resource); state.currentResource = resource; state.currentVolume = useVolume; + // Now-Playing broadcast + const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name; + nowPlaying.set(guildId, soundLabel); + sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel }); if (relativeKey) incrementPlaysFor(relativeKey); } @@ -1080,6 +1086,9 @@ app.post('/api/play', async (req: Request, res: Response) => { persistedState.volumes[guildId] = volumeToUse; writePersistedState(persistedState); console.log(`${new Date().toISOString()} | player.play() called for ${soundName}`); + // Now-Playing broadcast + nowPlaying.set(guildId!, soundName!); + sseBroadcast({ type: 'nowplaying', guildId, name: soundName }); // Plays zählen (relativer Key verfügbar?) if (relativePath) incrementPlaysFor(relativePath); return res.json({ ok: true }); @@ -1139,6 +1148,9 @@ app.post('/api/stop', (req: Request, res: Response) => { const state = guildAudioState.get(guildId); if (!state) return res.status(404).json({ error: 'Kein aktiver Player' }); state.player.stop(true); + // Now-Playing löschen + nowPlaying.delete(guildId); + sseBroadcast({ type: 'nowplaying', guildId, name: '' }); // Partymode für diese Guild ebenfalls stoppen try { const t = partyTimers.get(guildId); @@ -1240,7 +1252,7 @@ app.get('/api/events', (req: Request, res: Response) => { // Snapshot senden try { - res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {} })}\n\n`); + res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`); } catch {} // Ping, damit Proxies die Verbindung offen halten diff --git a/web/src/App.tsx b/web/src/App.tsx index c88c4c8..b382aaa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -154,12 +154,20 @@ export default function App() { const g = selectedRef.current?.split(':')[0]; if (g && typeof vols[g] === 'number') setVolume(vols[g]); } catch { } + try { + const np = msg?.nowplaying || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof np[g] === 'string') setLastPlayed(np[g]); + } catch { } } else if (msg?.type === 'channel') { const g = selectedRef.current?.split(':')[0]; if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); } else if (msg?.type === 'volume') { const g = selectedRef.current?.split(':')[0]; if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); + } else if (msg?.type === 'nowplaying') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) setLastPlayed(msg.name || ''); } }); return () => { try { unsub(); } catch { } }; @@ -362,6 +370,15 @@ export default function App() {
+ {lastPlayed && ( +
+
+
+
+
+ {lastPlayed} +
+ )} {selected && (
@@ -594,18 +611,6 @@ export default function App() { )} - {/* ═══ BOTTOM BAR ═══ */} -
-
-
-
-
-
- Spielt: - {lastPlayed || '—'} -
-
- {/* ═══ CONTEXT MENU ═══ */} {ctxMenu && (
Date: Sun, 1 Mar 2026 16:14:46 +0100 Subject: [PATCH 13/22] Restore admin sound management UI in web app --- web/src/App.tsx | 239 ++++++++++++++++++++++++++++++++++++++++++--- web/src/styles.css | 204 +++++++++++++++++++++++++++++++++++++- 2 files changed, 429 insertions(+), 14 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index b382aaa..dd8ca1f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, - adminStatus, adminLogin, adminLogout, adminDelete, + adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, } from './api'; @@ -60,6 +60,12 @@ export default function App() { const [isAdmin, setIsAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [adminPwd, setAdminPwd] = useState(''); + const [adminSounds, setAdminSounds] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); + const [adminQuery, setAdminQuery] = useState(''); + const [adminSelection, setAdminSelection] = useState>({}); + const [renameTarget, setRenameTarget] = useState(''); + const [renameValue, setRenameValue] = useState(''); /* ── UI ── */ const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); @@ -76,6 +82,7 @@ export default function App() { setNotification({ msg, type }); setTimeout(() => setNotification(null), 3000); }, []); + const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); const guildId = selected ? selected.split(':')[0] : ''; const channelId = selected ? selected.split(':')[1] : ''; @@ -218,6 +225,12 @@ export default function App() { return () => document.removeEventListener('click', handler); }, []); + useEffect(() => { + if (showAdmin && isAdmin) { + void loadAdminSounds(); + } + }, [showAdmin, isAdmin]); + /* ── Actions ── */ async function handlePlay(s: Sound) { if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); @@ -260,16 +273,84 @@ export default function App() { setFavs(prev => ({ ...prev, [key]: !prev[key] })); } + async function loadAdminSounds() { + setAdminLoading(true); + try { + const data = await fetchSounds('', '__all__', undefined, false); + setAdminSounds(data.items || []); + } catch (e: any) { + notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); + } finally { + setAdminLoading(false); + } + } + + function toggleAdminSelection(path: string) { + setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); + } + + function startRename(sound: Sound) { + setRenameTarget(soundKey(sound)); + setRenameValue(sound.name); + } + + function cancelRename() { + setRenameTarget(''); + setRenameValue(''); + } + + async function submitRename() { + if (!renameTarget) return; + const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); + if (!baseName) { + notify('Bitte einen gültigen Namen eingeben', 'error'); + return; + } + try { + await adminRename(renameTarget, baseName); + notify('Sound umbenannt'); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); + } + } + + async function deleteAdminPaths(paths: string[]) { + if (paths.length === 0) return; + try { + await adminDelete(paths); + notify(paths.length === 1 ? 'Sound gelöscht' : `${paths.length} Sounds gelöscht`); + setAdminSelection({}); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Löschen fehlgeschlagen', 'error'); + } + } + async function handleAdminLogin() { try { const ok = await adminLogin(adminPwd); - if (ok) { setIsAdmin(true); setAdminPwd(''); notify('Admin eingeloggt'); } + 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 { } + try { + await adminLogout(); + setIsAdmin(false); + setAdminSelection({}); + cancelRename(); + notify('Ausgeloggt'); + } catch { } } /* ── Computed ── */ @@ -309,6 +390,27 @@ export default function App() { return groups; }, [channels]); + const adminFilteredSounds = useMemo(() => { + const q = adminQuery.trim().toLowerCase(); + if (!q) return adminSounds; + return adminSounds.filter(s => { + const key = soundKey(s).toLowerCase(); + return s.name.toLowerCase().includes(q) + || (s.folder || '').toLowerCase().includes(q) + || key.includes(q); + }); + }, [adminQuery, adminSounds, soundKey]); + + const selectedAdminPaths = useMemo(() => + Object.keys(adminSelection).filter(k => adminSelection[k]), + [adminSelection]); + + const selectedVisibleCount = useMemo(() => + adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, + [adminFilteredSounds, adminSelection, soundKey]); + + const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; + const clockMain = clock.slice(0, 5); const clockSec = clock.slice(5); @@ -636,11 +738,7 @@ export default function App() {
{ const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; - try { - await adminDelete([path]); - notify('Sound gelöscht'); - setRefreshKey(k => k + 1); - } catch { notify('Löschen fehlgeschlagen', 'error'); } + await deleteAdminPaths([path]); setCtxMenu(null); }}> delete @@ -686,9 +784,128 @@ export default function App() {
) : ( -
-

Eingeloggt als Admin

- +
+
+

Eingeloggt als Admin

+
+ + +
+
+ +
+ + setAdminQuery(e.target.value)} + placeholder="Nach Name, Ordner oder Pfad filtern..." + /> +
+ +
+ + + +
+ +
+ {adminLoading ? ( +
Lade Sounds...
+ ) : adminFilteredSounds.length === 0 ? ( +
Keine Sounds gefunden.
+ ) : ( +
+ {adminFilteredSounds.map(sound => { + const key = soundKey(sound); + const editing = renameTarget === key; + return ( +
+ + +
+
{sound.name}
+
+ {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} + {' · '} + {key} +
+ {editing && ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void submitRename(); + if (e.key === 'Escape') cancelRename(); + }} + placeholder="Neuer Name..." + /> + + +
+ )} +
+ + {!editing && ( +
+ + +
+ )} +
+ ); + })} +
+ )} +
)}
diff --git a/web/src/styles.css b/web/src/styles.css index 2c9ea49..fabf398 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1248,18 +1248,22 @@ input, select { border: 1px solid rgba(255, 255, 255, .08); border-radius: var(--radius-lg); padding: 28px; - width: 90%; - max-width: 400px; + width: 92%; + max-width: 920px; + max-height: min(88vh, 860px); + display: flex; + flex-direction: column; box-shadow: var(--shadow-high); } .admin-panel h3 { font-size: 18px; font-weight: 700; - margin-bottom: 20px; + margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; } .admin-close { @@ -1318,6 +1322,7 @@ input, select { font-family: var(--font); cursor: pointer; transition: all var(--transition); + line-height: 1; } .admin-btn-action.primary { @@ -1341,6 +1346,180 @@ input, select { color: var(--text-normal); } +.admin-btn-action.danger { + background: var(--red); + color: var(--white); + border: 1px solid var(--red); +} + +.admin-btn-action.danger:hover { + filter: brightness(1.06); +} + +.admin-btn-action.danger.ghost { + background: transparent; + color: var(--red); + border: 1px solid rgba(242, 63, 66, .5); +} + +.admin-btn-action.danger.ghost:hover { + background: rgba(242, 63, 66, .14); +} + +.admin-btn-action:disabled { + opacity: .5; + pointer-events: none; +} + +.admin-shell { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.admin-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.admin-status { + font-size: 13px; + color: var(--text-muted); +} + +.admin-actions-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-search-field { + margin-bottom: 0; +} + +.admin-bulk-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .08); + flex-wrap: wrap; +} + +.admin-select-all { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); +} + +.admin-select-all input, +.admin-item-check input { + accent-color: var(--accent); +} + +.admin-list-wrap { + min-height: 260px; + max-height: 52vh; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + background: var(--bg-primary); +} + +.admin-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px; +} + +.admin-empty { + padding: 24px 12px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.admin-item { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.admin-item-main { + min-width: 0; +} + +.admin-item-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-item-meta { + margin-top: 3px; + font-size: 11px; + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-item-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.admin-item-actions .admin-btn-action, +.admin-rename-row .admin-btn-action { + padding: 8px 12px; + font-size: 12px; +} + +.admin-rename-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; +} + +.admin-rename-row input { + flex: 1; + min-width: 120px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + color: var(--text-normal); + font-family: var(--font); + transition: all var(--transition); +} + +.admin-rename-row input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + /* ──────────────────────────────────────────── Responsive ──────────────────────────────────────────── */ @@ -1396,6 +1575,25 @@ input, select { .tb-btn span:not(.tb-icon) { display: none; } + + .admin-panel { + width: 96%; + padding: 16px; + max-height: 92vh; + } + + .admin-item { + grid-template-columns: 24px minmax(0, 1fr); + } + + .admin-item-actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .admin-rename-row { + flex-wrap: wrap; + } } @media (max-width: 480px) { From 5a41b6a6225ff8f419083a580960d0401b86e3f1 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 18:56:37 +0100 Subject: [PATCH 14/22] Add MP3 URL import and analytics widgets --- server/src/index.ts | 115 ++++++++++++++++++++++++++----- web/src/App.tsx | 100 ++++++++++++++++++++++++++- web/src/api.ts | 8 ++- web/src/styles.css | 164 ++++++++++++++++++++++++++++++++++++++++++++ web/src/types.ts | 12 ++++ 5 files changed, 380 insertions(+), 19 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 174ea64..a9912f6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -557,6 +557,83 @@ app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length }); }); +type ListedSound = { + fileName: string; + name: string; + folder: string; + relativePath: string; +}; + +function listAllSounds(): ListedSound[] { + const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); + const rootFiles: ListedSound[] = rootEntries + .filter((d) => { + if (!d.isFile()) return false; + const n = d.name.toLowerCase(); + return n.endsWith('.mp3') || n.endsWith('.wav'); + }) + .map((d) => ({ + fileName: d.name, + name: path.parse(d.name).name, + folder: '', + relativePath: d.name, + })); + + const folderItems: ListedSound[] = []; + const subFolders = rootEntries.filter((d) => d.isDirectory()); + for (const dirent of subFolders) { + const folderName = dirent.name; + const folderPath = path.join(SOUNDS_DIR, folderName); + const entries = fs.readdirSync(folderPath, { withFileTypes: true }); + for (const e of entries) { + if (!e.isFile()) continue; + const n = e.name.toLowerCase(); + if (!(n.endsWith('.mp3') || n.endsWith('.wav'))) continue; + folderItems.push({ + fileName: e.name, + name: path.parse(e.name).name, + folder: folderName, + relativePath: path.join(folderName, e.name), + }); + } + } + + return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name)); +} + +app.get('/api/analytics', (_req: Request, res: Response) => { + try { + const allItems = listAllSounds(); + const byKey = new Map(); + for (const it of allItems) { + byKey.set(it.relativePath, it); + if (!byKey.has(it.fileName)) byKey.set(it.fileName, it); + } + + const mostPlayed = Object.entries(persistedState.plays ?? {}) + .map(([rel, count]) => { + const item = byKey.get(rel); + if (!item) return null; + return { + name: item.name, + relativePath: item.relativePath, + count: Number(count) || 0, + }; + }) + .filter((x): x is { name: string; relativePath: string; count: number } => !!x) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + .slice(0, 10); + + res.json({ + totalSounds: allItems.length, + totalPlays: persistedState.totalPlays ?? 0, + mostPlayed, + }); + } catch (e: any) { + res.status(500).json({ error: e?.message ?? 'Analytics konnten nicht geladen werden' }); + } +}); + // --- Admin Auth --- type AdminPayload = { iat: number; exp: number }; function b64url(input: Buffer | string): string { @@ -1280,28 +1357,34 @@ app.listen(PORT, () => { }); // --- Medien-URL abspielen --- -// Unterstützt: direkte MP3- oder WAV-URL (Download und Ablage) +// Unterstützt: direkte MP3-URL (Download und Ablage) app.post('/api/play-url', async (req: Request, res: Response) => { try { const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number }; if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); - const lower = url.toLowerCase(); - if (lower.endsWith('.mp3') || lower.endsWith('.wav')) { - const fileName = path.basename(new URL(url).pathname); - const dest = path.join(SOUNDS_DIR, fileName); - const r = await fetch(url); - if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' }); - const buf = Buffer.from(await r.arrayBuffer()); - fs.writeFileSync(dest, buf); - try { - await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); - } catch { - return res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); - } - return res.json({ ok: true, saved: path.basename(dest) }); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: 'Ungültige URL' }); } - return res.status(400).json({ error: 'Nur MP3- oder WAV-Links werden unterstützt.' }); + const pathname = parsed.pathname.toLowerCase(); + if (!pathname.endsWith('.mp3')) { + return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' }); + } + const fileName = path.basename(parsed.pathname); + const dest = path.join(SOUNDS_DIR, fileName); + const r = await fetch(url); + if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' }); + const buf = Buffer.from(await r.arrayBuffer()); + fs.writeFileSync(dest, buf); + try { + await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); + } catch { + return res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); + } + return res.json({ ok: true, saved: path.basename(dest) }); } catch (e: any) { console.error('play-url error:', e); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); diff --git a/web/src/App.tsx b/web/src/App.tsx index dd8ca1f..d81963e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,11 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { - fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, + fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, } from './api'; -import type { VoiceChannelInfo, Sound, Category } from './types'; +import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; import { getCookie, setCookie } from './cookies'; const THEMES = [ @@ -30,11 +30,18 @@ export default function App() { const [total, setTotal] = useState(0); const [folders, setFolders] = useState>([]); const [categories, setCategories] = useState([]); + const [analytics, setAnalytics] = useState({ + totalSounds: 0, + totalPlays: 0, + mostPlayed: [], + }); /* ── Navigation ── */ const [activeTab, setActiveTab] = useState('all'); const [activeFolder, setActiveFolder] = useState(''); const [query, setQuery] = useState(''); + const [importUrl, setImportUrl] = useState(''); + const [importBusy, setImportBusy] = useState(false); /* ── Channels ── */ const [channels, setChannels] = useState([]); @@ -83,6 +90,14 @@ export default function App() { setTimeout(() => setNotification(null), 3000); }, []); const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); + const isMp3Url = useCallback((value: string) => { + try { + const parsed = new URL(value.trim()); + return parsed.pathname.toLowerCase().endsWith('.mp3'); + } catch { + return false; + } + }, []); const guildId = selected ? selected.split(':')[0] : ''; const channelId = selected ? selected.split(':')[1] : ''; @@ -199,6 +214,10 @@ export default function App() { })(); }, [activeTab, activeFolder, query, refreshKey]); + useEffect(() => { + void loadAnalytics(); + }, [refreshKey]); + /* ── Favs persistence ── */ useEffect(() => { const c = getCookie('favs'); @@ -232,14 +251,41 @@ export default function App() { }, [showAdmin, isAdmin]); /* ── Actions ── */ + async function loadAnalytics() { + try { + const data = await fetchAnalytics(); + setAnalytics(data); + } catch { } + } + async function handlePlay(s: Sound) { if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); try { await playSound(s.name, guildId, channelId, volume, s.relativePath); setLastPlayed(s.name); + void loadAnalytics(); } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } + async function handleUrlImport() { + const trimmed = importUrl.trim(); + if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); + if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); + setImportBusy(true); + try { + await playUrl(trimmed, guildId, channelId, volume); + setImportUrl(''); + notify('MP3 importiert und abgespielt'); + setRefreshKey(k => k + 1); + await loadAnalytics(); + } catch (e: any) { + notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); + } finally { + setImportBusy(false); + } + } + async function handleStop() { if (!selected) return; setLastPlayed(''); @@ -410,6 +456,8 @@ export default function App() { [adminFilteredSounds, adminSelection, soundKey]); const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; + const analyticsTop = analytics.mostPlayed.slice(0, 3); + const totalSoundsDisplay = analytics.totalSounds || total; const clockMain = clock.slice(0, 5); const clockSec = clock.slice(5); @@ -538,6 +586,26 @@ export default function App() { )}
+
+ link + setImportUrl(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} + /> + +
+
@@ -612,6 +680,34 @@ export default function App() {
+
+
+ library_music +
+ Sounds gesamt + {totalSoundsDisplay} +
+
+ +
+ leaderboard +
+ Most Played +
+ {analyticsTop.length === 0 ? ( + Noch keine Plays + ) : ( + analyticsTop.map((item, idx) => ( + + {idx + 1}. {item.name} ({item.count}) + + )) + )} +
+
+
+
+ {/* ═══ FOLDER CHIPS ═══ */} {activeTab === 'all' && visibleFolders.length > 0 && (
diff --git a/web/src/api.ts b/web/src/api.ts index cd61520..4b6b0fd 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Sound, SoundsResponse, VoiceChannelInfo } from './types'; +import type { AnalyticsResponse, Sound, SoundsResponse, VoiceChannelInfo } from './types'; const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; @@ -13,6 +13,12 @@ export async function fetchSounds(q?: string, folderKey?: string, categoryId?: s return res.json(); } +export async function fetchAnalytics(): Promise { + const res = await fetch(`${API_BASE}/analytics`); + if (!res.ok) throw new Error('Fehler beim Laden der Analytics'); + return res.json(); +} + // Kategorien export async function fetchCategories() { const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' }); diff --git a/web/src/styles.css b/web/src/styles.css index fabf398..66abf75 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -497,6 +497,66 @@ input, select { flex: 1; } +/* ── URL Import ── */ +.url-import-wrap { + display: flex; + align-items: center; + gap: 6px; + min-width: 240px; + max-width: 460px; + flex: 1; + padding: 4px 6px 4px 8px; + border-radius: 20px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .08); +} + +.url-import-icon { + font-size: 15px; + color: var(--text-faint); + flex-shrink: 0; +} + +.url-import-input { + flex: 1; + min-width: 0; + height: 26px; + border: none; + background: transparent; + color: var(--text-normal); + font-size: 12px; + font-family: var(--font); + outline: none; +} + +.url-import-input::placeholder { + color: var(--text-faint); +} + +.url-import-btn { + height: 24px; + padding: 0 10px; + border-radius: 14px; + border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .45); + background: rgba(var(--accent-rgb, 88, 101, 242), .12); + color: var(--accent); + font-size: 11px; + font-weight: 700; + white-space: nowrap; + transition: all var(--transition); +} + +.url-import-btn:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--white); +} + +.url-import-btn:disabled { + opacity: .5; + pointer-events: none; +} + /* ── Toolbar Buttons ── */ .tb-btn { display: flex; @@ -649,6 +709,90 @@ input, select { box-shadow: 0 0 6px rgba(255, 255, 255, .3); } +/* ── Analytics Strip ── */ +.analytics-strip { + display: flex; + align-items: stretch; + gap: 8px; + padding: 8px 20px; + background: var(--bg-primary); + border-bottom: 1px solid rgba(0, 0, 0, .12); + flex-shrink: 0; +} + +.analytics-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 12px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .08); +} + +.analytics-card.analytics-wide { + flex: 1; + min-width: 0; +} + +.analytics-icon { + font-size: 18px; + color: var(--accent); + flex-shrink: 0; +} + +.analytics-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.analytics-label { + font-size: 10px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text-faint); +} + +.analytics-value { + font-size: 18px; + line-height: 1; + font-weight: 800; + color: var(--text-normal); +} + +.analytics-top-list { + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; +} + +.analytics-top-list::-webkit-scrollbar { + display: none; +} + +.analytics-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + background: rgba(var(--accent-rgb, 88, 101, 242), .15); + color: var(--accent); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.analytics-muted { + color: var(--text-muted); + font-size: 12px; +} + /* ──────────────────────────────────────────── Category / Folder Strip ──────────────────────────────────────────── */ @@ -1544,6 +1688,12 @@ input, select { order: -1; } + .url-import-wrap { + max-width: 100%; + min-width: 100%; + order: -1; + } + .size-control, .theme-selector { display: none; @@ -1576,6 +1726,16 @@ input, select { display: none; } + .analytics-strip { + padding: 8px 12px; + flex-direction: column; + gap: 6px; + } + + .analytics-card.analytics-wide { + width: 100%; + } + .admin-panel { width: 96%; padding: 16px; @@ -1612,6 +1772,10 @@ input, select { .toolbar .tb-btn { padding: 6px 8px; } + + .url-import-btn { + padding: 0 8px; + } } /* ──────────────────────────────────────────── diff --git a/web/src/types.ts b/web/src/types.ts index 4cfbef3..919e8fe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -25,6 +25,18 @@ export type VoiceChannelInfo = { export type Category = { id: string; name: string; color?: string; sort?: number }; +export type AnalyticsItem = { + name: string; + relativePath: string; + count: number; +}; + +export type AnalyticsResponse = { + totalSounds: number; + totalPlays: number; + mostPlayed: AnalyticsItem[]; +}; + From 9130a205f07a3e4286a8476942e7693e855b5ecc Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:08:38 +0100 Subject: [PATCH 15/22] Refactor: Backend-Optimierungen + Volume-Debounce - /api/play delegiert an playFilePath() statt ~120 Zeilen Duplikat-Code (inkl. fehlende Loudnorm) - safeSoundsPath() Helfer gegen Path-Traversal bei Admin delete/rename - writePersistedStateDebounced() reduziert Disk-I/O bei Play-Countern (2s Debounce) - /api/sounds nutzt listAllSounds() statt duplizierte Dateisystem-Scans - /api/play-url vor catch-all Route verschoben (war unreachable in Produktion) - Frontend Volume-Slider mit 120ms Debounce (weniger API-Calls beim Ziehen) Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 207 +++++++++++--------------------------------- web/src/App.tsx | 10 ++- 2 files changed, 57 insertions(+), 160 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index a9912f6..81f220d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -116,16 +116,34 @@ function writePersistedState(state: PersistedState): void { } const persistedState: PersistedState = readPersistedState(); + +// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden +let _writeTimer: ReturnType | null = null; +function writePersistedStateDebounced(): void { + if (_writeTimer) return; + _writeTimer = setTimeout(() => { + _writeTimer = null; + writePersistedState(persistedState); + }, 2000); +} + const getPersistedVolume = (guildId: string): number => { const v = persistedState.volumes[guildId]; return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1; }; +/** Sicherstellen, dass ein relativer Pfad nicht aus SOUNDS_DIR ausbricht */ +function safeSoundsPath(rel: string): string | null { + const resolved = path.resolve(SOUNDS_DIR, rel); + if (!resolved.startsWith(path.resolve(SOUNDS_DIR) + path.sep) && resolved !== path.resolve(SOUNDS_DIR)) return null; + return resolved; +} + function incrementPlaysFor(relativePath: string) { try { const key = relativePath.replace(/\\/g, '/'); persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1; persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1; - writePersistedState(persistedState); + writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch } catch {} } @@ -699,40 +717,17 @@ app.get('/api/sounds', (req: Request, res: Response) => { const fuzzyParam = String((req.query as any).fuzzy ?? '0'); const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; - const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); - const rootFiles = rootEntries - .filter((d) => { - if (!d.isFile()) return false; - const n = d.name.toLowerCase(); - return n.endsWith('.mp3') || n.endsWith('.wav'); - }) - .map((d) => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name })); + const allItems = listAllSounds(); - const folders: Array<{ key: string; name: string; count: number }> = []; - - const subFolders = rootEntries.filter((d) => d.isDirectory()); - const folderItems: Array<{ fileName: string; name: string; folder: string; relativePath: string }> = []; - for (const dirent of subFolders) { - const folderName = dirent.name; - const folderPath = path.join(SOUNDS_DIR, folderName); - const entries = fs.readdirSync(folderPath, { withFileTypes: true }); - const audios = entries.filter((e) => { - if (!e.isFile()) return false; - const n = e.name.toLowerCase(); - return n.endsWith('.mp3') || n.endsWith('.wav'); - }); - for (const f of audios) { - folderItems.push({ - fileName: f.name, - name: path.parse(f.name).name, - folder: folderName, - relativePath: path.join(folderName, f.name) - }); - } - folders.push({ key: folderName, name: folderName, count: audios.length }); + // Ordner-Statistik aus allItems ableiten + const folderCounts = new Map(); + for (const it of allItems) { + if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1); + } + const folders: Array<{ key: string; name: string; count: number }> = []; + for (const [key, count] of folderCounts) { + folders.push({ key, name: key, count }); } - - const allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name)); // Zeitstempel für Neu-Logik type ItemWithTime = { fileName: string; name: string; folder: string; relativePath: string; mtimeMs: number }; @@ -856,7 +851,8 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response) if (!Array.isArray(paths) || paths.length === 0) return res.status(400).json({ error: 'paths[] erforderlich' }); const results: Array<{ path: string; ok: boolean; error?: string }> = []; for (const rel of paths) { - const full = path.join(SOUNDS_DIR, rel); + const full = safeSoundsPath(rel); + if (!full) { results.push({ path: rel, ok: false, error: 'Ungültiger Pfad' }); continue; } try { if (fs.existsSync(full) && fs.statSync(full).isFile()) { fs.unlinkSync(full); @@ -875,13 +871,14 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response) app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) => { const { from, to } = req.body as { from?: string; to?: string }; if (!from || !to) return res.status(400).json({ error: 'from und to erforderlich' }); - const src = path.join(SOUNDS_DIR, from); - // Ziel nur Name ändern, Endung mp3 sicherstellen + const src = safeSoundsPath(from); + if (!src) return res.status(400).json({ error: 'Ungültiger Quellpfad' }); const parsed = path.parse(from); // UTF-8 Zeichen erlauben: Leerzeichen, Umlaute, etc. - nur problematische Zeichen filtern const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`); - const dst = path.join(SOUNDS_DIR, dstRel); + const dst = safeSoundsPath(dstRel); + if (!dst) return res.status(400).json({ error: 'Ungültiger Zielpfad' }); try { if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' }); if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' }); @@ -1051,7 +1048,6 @@ app.post('/api/play', async (req: Request, res: Response) => { let filePath: string; if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); else if (folder) { - // Bevorzugt .mp3, fallback .wav const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); const wav = path.join(SOUNDS_DIR, folder, `${soundName}.wav`); filePath = fs.existsSync(mp3) ? mp3 : wav; @@ -1062,112 +1058,9 @@ app.post('/api/play', async (req: Request, res: Response) => { } if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); - const guild = client.guilds.cache.get(guildId); - if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); - const channel = guild.channels.cache.get(channelId); - if (!channel || (channel.type !== ChannelType.GuildVoice && channel.type !== ChannelType.GuildStageVoice)) { - return res.status(400).json({ error: 'Ungültiger Voice-Channel' }); - } - - let state = guildAudioState.get(guildId); - if (!state) { - const connection = joinVoiceChannel({ - channelId, - guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); - connection.subscribe(player); - state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) }; - guildAudioState.set(guildId, state); - - // Connection State Logs - connection.on('stateChange', (oldState, newState) => { - console.log(`${new Date().toISOString()} | VoiceConnection: ${oldState.status} -> ${newState.status}`); - }); - player.on('stateChange', (oldState, newState) => { - console.log(`${new Date().toISOString()} | AudioPlayer: ${oldState.status} -> ${newState.status}`); - }); - player.on('error', (err) => { - console.error(`${new Date().toISOString()} | AudioPlayer error:`, err); - }); - - state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); - attachVoiceLifecycle(state, guild); - - // Stage-Channel Entstummung anfordern/setzen - try { - const me = guild.members.me; - if (me && (channel.type === ChannelType.GuildStageVoice)) { - if ((me.voice as any)?.suppress) { - await me.voice.setSuppressed(false).catch(() => me.voice.setRequestToSpeak(true)); - console.log(`${new Date().toISOString()} | StageVoice: suppression versucht zu deaktivieren`); - } - } - } catch (e) { - console.warn(`${new Date().toISOString()} | StageVoice unsuppress/requestToSpeak fehlgeschlagen`, e); - } - - state.player.on(AudioPlayerStatus.Idle, () => { - // optional: Verbindung bestehen lassen oder nach Timeout trennen - }); - } else { - // Wechsel in anderen Channel, wenn nötig - const current = getVoiceConnection(guildId); - if (current && (current.joinConfig.channelId !== channelId)) { - current.destroy(); - const connection = joinVoiceChannel({ - channelId, - guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); - connection.subscribe(player); - state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) }; - guildAudioState.set(guildId, state); - - state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); - attachVoiceLifecycle(state, guild); - - connection.on('stateChange', (o, n) => { - console.log(`${new Date().toISOString()} | VoiceConnection: ${o.status} -> ${n.status}`); - }); - player.on('stateChange', (o, n) => { - console.log(`${new Date().toISOString()} | AudioPlayer: ${o.status} -> ${n.status}`); - }); - player.on('error', (err) => { - console.error(`${new Date().toISOString()} | AudioPlayer error:`, err); - }); - } - } - - console.log(`${new Date().toISOString()} | createAudioResource: ${filePath}`); - // Volume bestimmen: bevorzugt Request-Volume, sonst bisheriger State-Wert, sonst 1 - const volumeToUse = typeof volume === 'number' && Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : (state.currentVolume ?? 1); - const resource = createAudioResource(filePath, { inlineVolume: true }); - if (resource.volume) { - resource.volume.setVolume(volumeToUse); - console.log(`${new Date().toISOString()} | setVolume(${volumeToUse}) for ${soundName}`); - } - state.player.stop(); - state.player.play(resource); - state.currentResource = resource; - state.currentVolume = volumeToUse; - // Persistieren - persistedState.volumes[guildId] = volumeToUse; - writePersistedState(persistedState); - console.log(`${new Date().toISOString()} | player.play() called for ${soundName}`); - // Now-Playing broadcast - nowPlaying.set(guildId!, soundName!); - sseBroadcast({ type: 'nowplaying', guildId, name: soundName }); - // Plays zählen (relativer Key verfügbar?) - if (relativePath) incrementPlaysFor(relativePath); + // Delegiere an playFilePath (inkl. Loudnorm, Connection-Management, Now-Playing Broadcast) + const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName); + await playFilePath(guildId, channelId, filePath, volume, relKey!); return res.json({ ok: true }); } catch (err: any) { console.error('Play-Fehler:', err); @@ -1343,19 +1236,6 @@ app.get('/api/events', (req: Request, res: Response) => { }); }); -// Static Frontend ausliefern (Vite build) -const webDistPath = path.resolve(__dirname, '../../web/dist'); -if (fs.existsSync(webDistPath)) { - app.use(express.static(webDistPath)); - app.get('*', (_req, res) => { - res.sendFile(path.join(webDistPath, 'index.html')); - }); -} - -app.listen(PORT, () => { - console.log(`Server läuft auf http://0.0.0.0:${PORT}`); -}); - // --- Medien-URL abspielen --- // Unterstützt: direkte MP3-URL (Download und Ablage) app.post('/api/play-url', async (req: Request, res: Response) => { @@ -1391,7 +1271,18 @@ app.post('/api/play-url', async (req: Request, res: Response) => { } }); -// Upload endpoint removed (build reverted) +// Static Frontend ausliefern (Vite build) +const webDistPath = path.resolve(__dirname, '../../web/dist'); +if (fs.existsSync(webDistPath)) { + app.use(express.static(webDistPath)); + app.get('*', (_req, res) => { + res.sendFile(path.join(webDistPath, 'index.html')); + }); +} + +app.listen(PORT, () => { + console.log(`Server läuft auf http://0.0.0.0:${PORT}`); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index d81963e..5bd90c5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -62,6 +62,7 @@ export default function App() { const [chaosMode, setChaosMode] = useState(false); const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); const chaosModeRef = useRef(false); + const volDebounceRef = useRef>(); /* ── Admin ── */ const [isAdmin, setIsAdmin] = useState(false); @@ -626,10 +627,15 @@ export default function App() { max={1} step={0.01} value={volume} - onChange={async e => { + onChange={e => { const v = parseFloat(e.target.value); setVolume(v); - if (guildId) try { await setVolumeLive(guildId, v); } catch { } + if (guildId) { + if (volDebounceRef.current) clearTimeout(volDebounceRef.current); + volDebounceRef.current = setTimeout(() => { + setVolumeLive(guildId, v).catch(() => {}); + }, 120); + } }} style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} /> From 4b4a61b2bd84ef914b67c155a73181060ee2aefd Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:16:56 +0100 Subject: [PATCH 16/22] =?UTF-8?q?Perf:=20Loudnorm-Cache=20f=C3=BCr=20minim?= =?UTF-8?q?ale=20Play-Latenz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg loudnorm läuft jetzt nur noch beim allerersten Play eines Sounds. Das Ergebnis wird als PCM in .norm-cache/ gespeichert und danach direkt abgespielt (kein ffmpeg-Spawn mehr → praktisch null Overhead). - .norm-cache/ Verzeichnis mit automatischer Invalidierung (Quell-mtime) - Cache-Cleanup bei Admin delete/rename - Hintergrund-Warmup beim Serverstart: Top-50 Sounds vorab normalisieren - Fallback auf direktes Abspielen wenn ffmpeg fehlschlägt - .norm-cache wird aus Soundliste gefiltert Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 101 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 81f220d..ee7138f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -153,6 +153,45 @@ const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16'); const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11'); const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5'); +// Loudnorm-Cache: normalisierte PCM-Dateien werden gecacht, damit ffmpeg nur 1× pro Sound läuft +const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache'); +fs.mkdirSync(NORM_CACHE_DIR, { recursive: true }); + +/** Erzeugt einen Cache-Key aus dem relativen Pfad (Slash-normalisiert, Sonderzeichen escaped) */ +function normCacheKey(filePath: string): string { + const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/'); + return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm'; +} + +/** Liefert den Pfad zur gecachten normalisierten PCM-Datei, oder null wenn kein gültiger Cache existiert */ +function getNormCachePath(filePath: string): string | null { + const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); + if (!fs.existsSync(cacheFile)) return null; + // Invalidieren wenn Quelldatei neuer als Cache + try { + const srcMtime = fs.statSync(filePath).mtimeMs; + const cacheMtime = fs.statSync(cacheFile).mtimeMs; + if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} return null; } + } catch { return null; } + return cacheFile; +} + +/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ +function normalizeToCache(filePath: string): Promise { + const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); + return new Promise((resolve, reject) => { + const ffArgs = ['-hide_banner', '-loglevel', 'error', '-i', filePath, + '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, + '-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]; + const ff = child_process.spawn('ffmpeg', ffArgs); + ff.on('error', reject); + ff.on('close', (code) => { + if (code === 0) resolve(cacheFile); + else reject(new Error(`ffmpeg exited with code ${code}`)); + }); + }); +} + // --- Voice Abhängigkeiten prüfen --- await sodium.ready; // init nacl to ensure it loads @@ -272,11 +311,29 @@ async function playFilePath(guildId: string, channelId: string, filePath: string : (state.currentVolume ?? 1); let resource: AudioResource; if (NORMALIZE_ENABLE) { - const ffArgs = ['-hide_banner', '-loglevel', 'error', '-i', filePath, - '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, - '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']; - const ff = child_process.spawn('ffmpeg', ffArgs); - resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw }); + // Cache-basierte Normalisierung: ffmpeg läuft nur beim 1. Mal + let cachedPath = getNormCachePath(filePath); + if (!cachedPath) { + try { + cachedPath = await normalizeToCache(filePath); + console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`); + } catch (e) { + console.warn(`${new Date().toISOString()} | Loudnorm cache failed, fallback direct:`, e); + // Fallback: direkt ohne Normalisierung abspielen + resource = createAudioResource(filePath, { inlineVolume: true }); + if (resource.volume) resource.volume.setVolume(useVolume); + state.player.stop(); + state.player.play(resource); + state.currentResource = resource; + state.currentVolume = useVolume; + const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name; + nowPlaying.set(guildId, soundLabel); + sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel }); + if (relativeKey) incrementPlaysFor(relativeKey); + return; + } + } + resource = createAudioResource(cachedPath, { inlineVolume: true, inputType: StreamType.Raw }); } else { resource = createAudioResource(filePath, { inlineVolume: true }); } @@ -598,7 +655,7 @@ function listAllSounds(): ListedSound[] { })); const folderItems: ListedSound[] = []; - const subFolders = rootEntries.filter((d) => d.isDirectory()); + const subFolders = rootEntries.filter((d) => d.isDirectory() && d.name !== '.norm-cache'); for (const dirent of subFolders) { const folderName = dirent.name; const folderPath = path.join(SOUNDS_DIR, folderName); @@ -856,6 +913,8 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response) try { if (fs.existsSync(full) && fs.statSync(full).isFile()) { fs.unlinkSync(full); + // Loudnorm-Cache aufräumen + try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {} results.push({ path: rel, ok: true }); } else { results.push({ path: rel, ok: false, error: 'nicht gefunden' }); @@ -883,6 +942,8 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' }); if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' }); fs.renameSync(src, dst); + // Loudnorm-Cache für alte Datei aufräumen (neue wird beim nächsten Play erzeugt) + try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {} res.json({ ok: true, from, to: dstRel }); } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' }); @@ -1282,6 +1343,34 @@ if (fs.existsSync(webDistPath)) { app.listen(PORT, () => { console.log(`Server läuft auf http://0.0.0.0:${PORT}`); + + // Hintergrund-Warmup: häufigste Sounds vorab normalisieren, damit der erste Play sofort schnell ist + if (NORMALIZE_ENABLE) { + (async () => { + try { + const allSounds = listAllSounds(); + // Sortiere nach Play-Count (häufigste zuerst), maximal 50 vorab cachen + const plays = persistedState.plays ?? {}; + const sorted = [...allSounds].sort((a, b) => ((plays[b.relativePath] ?? 0) as number) - ((plays[a.relativePath] ?? 0) as number)); + const toWarm = sorted.slice(0, 50); + let cached = 0; + for (const s of toWarm) { + const fp = path.join(SOUNDS_DIR, s.relativePath); + if (!fs.existsSync(fp)) continue; + if (getNormCachePath(fp)) continue; // schon gecacht + try { + await normalizeToCache(fp); + cached++; + } catch (e) { + console.warn(`Warmup failed for ${s.relativePath}:`, e); + } + } + if (cached > 0) console.log(`Loudnorm-Warmup: ${cached} Sounds vorab normalisiert`); + } catch (e) { + console.warn('Loudnorm-Warmup error:', e); + } + })(); + } }); From 83b8f1acace8c23d03fdf5dab5fb63b5b7f56c44 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:24:47 +0100 Subject: [PATCH 17/22] Fix: Loudnorm-Cache korrekt als Stream lesen + Tee-Caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Fehler behoben: 1. Gecachte PCM-Datei wurde als Pfad-String an createAudioResource übergeben → discord.js versuchte die headerlose PCM als Container zu proben → Rauschen/Stille Fix: fs.createReadStream() + StreamType.Raw 2. Erster Play wartete auf komplette ffmpeg-Verarbeitung bevor Wiedergabe startete Fix: Tee-Stream — ffmpeg-Output wird gleichzeitig an Player UND Cache-Datei geschrieben → Sofortige Wiedergabe auch beim ersten Mal, Cache wird nebenbei gefüllt Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 50 ++++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index ee7138f..35e701e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,7 @@ import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; // Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt import child_process from 'node:child_process'; +import { PassThrough } from 'node:stream'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -311,29 +312,36 @@ async function playFilePath(guildId: string, channelId: string, filePath: string : (state.currentVolume ?? 1); let resource: AudioResource; if (NORMALIZE_ENABLE) { - // Cache-basierte Normalisierung: ffmpeg läuft nur beim 1. Mal - let cachedPath = getNormCachePath(filePath); - if (!cachedPath) { - try { - cachedPath = await normalizeToCache(filePath); + const cachedPath = getNormCachePath(filePath); + if (cachedPath) { + // Cache-Hit: gecachte PCM-Datei als Stream lesen (kein ffmpeg, instant) + const pcmStream = fs.createReadStream(cachedPath); + resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw }); + } else { + // Cache-Miss: ffmpeg streamen (sofortige Wiedergabe) UND gleichzeitig in Cache schreiben + const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); + const ffArgs = ['-hide_banner', '-loglevel', 'error', '-i', filePath, + '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, + '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']; + const ff = child_process.spawn('ffmpeg', ffArgs); + // Tee: Daten gleichzeitig an Player und Cache-Datei + const playerStream = new PassThrough(); + const cacheWrite = fs.createWriteStream(cacheFile); + ff.stdout.on('data', (chunk: Buffer) => { + playerStream.write(chunk); + cacheWrite.write(chunk); + }); + ff.stdout.on('end', () => { + playerStream.end(); + cacheWrite.end(); console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`); - } catch (e) { - console.warn(`${new Date().toISOString()} | Loudnorm cache failed, fallback direct:`, e); - // Fallback: direkt ohne Normalisierung abspielen - resource = createAudioResource(filePath, { inlineVolume: true }); - if (resource.volume) resource.volume.setVolume(useVolume); - state.player.stop(); - state.player.play(resource); - state.currentResource = resource; - state.currentVolume = useVolume; - const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name; - nowPlaying.set(guildId, soundLabel); - sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel }); - if (relativeKey) incrementPlaysFor(relativeKey); - return; - } + }); + ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} }); + ff.on('close', (code) => { + if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } + }); + resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw }); } - resource = createAudioResource(cachedPath, { inlineVolume: true, inputType: StreamType.Raw }); } else { resource = createAudioResource(filePath, { inlineVolume: true }); } From 68414ac25710eac5a27d65deb7573a5fea126966 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:27:33 +0100 Subject: [PATCH 18/22] =?UTF-8?q?Perf:=20Vollst=C3=A4ndiger=20Norm-Cache-S?= =?UTF-8?q?ync=20beim=20Start=20+=20Auto-Cache=20bei=20Upload/Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncNormCache() beim Serverstart: normalisiert ALLE Sounds (nicht nur Top 50) und räumt verwaiste Cache-Dateien automatisch auf - DM-Upload: neue Datei wird sofort im Hintergrund normalisiert - URL-Import: Datei wird vor dem Abspielen normalisiert → direkt aus Cache - Detailliertes Logging: neu/vorhanden/fehlgeschlagen/verwaist + Dauer Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 87 ++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 35e701e..b77b970 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -181,7 +181,7 @@ function getNormCachePath(filePath: string): string | null { function normalizeToCache(filePath: string): Promise { const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); return new Promise((resolve, reject) => { - const ffArgs = ['-hide_banner', '-loglevel', 'error', '-i', filePath, + const ffArgs = ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath, '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, '-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]; const ff = child_process.spawn('ffmpeg', ffArgs); @@ -193,6 +193,54 @@ function normalizeToCache(filePath: string): Promise { }); } +/** + * Vollständige Cache-Synchronisation: + * 1. Alle Sound-Dateien normalisieren, die noch nicht im Cache sind + * 2. Verwaiste Cache-Dateien löschen (Sound wurde gelöscht/umbenannt) + * Läuft im Hintergrund, blockiert nicht den Server. + */ +async function syncNormCache(): Promise { + if (!NORMALIZE_ENABLE) return; + const t0 = Date.now(); + const allSounds = listAllSounds(); + + // Set aller erwarteten Cache-Keys + const expectedKeys = new Set(); + let created = 0; + let skipped = 0; + let failed = 0; + + for (const s of allSounds) { + const fp = path.join(SOUNDS_DIR, s.relativePath); + const key = normCacheKey(fp); + expectedKeys.add(key); + if (!fs.existsSync(fp)) continue; + if (getNormCachePath(fp)) { skipped++; continue; } // bereits gecacht & gültig + try { + await normalizeToCache(fp); + created++; + } catch (e) { + failed++; + console.warn(`Norm-cache failed: ${s.relativePath}`, e); + } + } + + // Verwaiste Cache-Dateien aufräumen + let cleaned = 0; + try { + for (const f of fs.readdirSync(NORM_CACHE_DIR)) { + if (!expectedKeys.has(f)) { + try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} + } + } + } catch {} + + const dt = ((Date.now() - t0) / 1000).toFixed(1); + console.log( + `Norm-Cache sync (${dt}s): ${created} neu, ${skipped} vorhanden, ${failed} fehlgeschlagen, ${cleaned} verwaist entfernt (${allSounds.length} Sounds gesamt)` + ); +} + // --- Voice Abhängigkeiten prüfen --- await sodium.ready; // init nacl to ensure it loads @@ -622,6 +670,10 @@ client.on(Events.MessageCreate, async (message: Message) => { if (!res.ok) throw new Error(`Download fehlgeschlagen: ${attachment.url}`); const arrayBuffer = await res.arrayBuffer(); fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); + // Sofort normalisieren für instant Play + if (NORMALIZE_ENABLE) { + normalizeToCache(targetPath).catch((e) => console.warn('Norm after upload failed:', e)); + } await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`); } } catch (err) { @@ -1328,6 +1380,10 @@ app.post('/api/play-url', async (req: Request, res: Response) => { if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' }); const buf = Buffer.from(await r.arrayBuffer()); fs.writeFileSync(dest, buf); + // Vor dem Abspielen normalisieren → sofort aus Cache + if (NORMALIZE_ENABLE) { + try { await normalizeToCache(dest); } catch (e) { console.warn('Norm after URL import failed:', e); } + } try { await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); } catch { @@ -1352,33 +1408,8 @@ if (fs.existsSync(webDistPath)) { app.listen(PORT, () => { console.log(`Server läuft auf http://0.0.0.0:${PORT}`); - // Hintergrund-Warmup: häufigste Sounds vorab normalisieren, damit der erste Play sofort schnell ist - if (NORMALIZE_ENABLE) { - (async () => { - try { - const allSounds = listAllSounds(); - // Sortiere nach Play-Count (häufigste zuerst), maximal 50 vorab cachen - const plays = persistedState.plays ?? {}; - const sorted = [...allSounds].sort((a, b) => ((plays[b.relativePath] ?? 0) as number) - ((plays[a.relativePath] ?? 0) as number)); - const toWarm = sorted.slice(0, 50); - let cached = 0; - for (const s of toWarm) { - const fp = path.join(SOUNDS_DIR, s.relativePath); - if (!fs.existsSync(fp)) continue; - if (getNormCachePath(fp)) continue; // schon gecacht - try { - await normalizeToCache(fp); - cached++; - } catch (e) { - console.warn(`Warmup failed for ${s.relativePath}:`, e); - } - } - if (cached > 0) console.log(`Loudnorm-Warmup: ${cached} Sounds vorab normalisiert`); - } catch (e) { - console.warn('Loudnorm-Warmup error:', e); - } - })(); - } + // Vollständige Cache-Synchronisation beim Start (Hintergrund) + syncNormCache(); }); From 7a7056d72b4fc01112ec7934a7951e87b2d38dbf Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:29:53 +0100 Subject: [PATCH 19/22] Perf: Paralleler Norm-Cache-Sync mit Worker-Pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequentieller Sync ersetzt durch parallele Worker: - Standard: 4 gleichzeitige ffmpeg-Prozesse (NORM_CONCURRENCY=4) - Konfigurierbar per NORM_CONCURRENCY env var - Sync-Zeit bei vielen Sounds 4x schneller (linear mit Parallelität) - Logging gibt Parallelität und Dauer aus Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index b77b970..101cfbc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -193,9 +193,14 @@ function normalizeToCache(filePath: string): Promise { }); } +// Wie viele ffmpeg-Prozesse parallel beim Cache-Sync laufen dürfen. +// Standard: 4 (gut für 4+ Kern CPUs ohne Discord-Wiedergabe zu stören). +// Über NORM_CONCURRENCY=8 o.ä. erhöhbar. +const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 4)); + /** * Vollständige Cache-Synchronisation: - * 1. Alle Sound-Dateien normalisieren, die noch nicht im Cache sind + * 1. Alle Sound-Dateien normalisieren, die noch nicht im Cache sind (parallel) * 2. Verwaiste Cache-Dateien löschen (Sound wurde gelöscht/umbenannt) * Läuft im Hintergrund, blockiert nicht den Server. */ @@ -206,24 +211,37 @@ async function syncNormCache(): Promise { // Set aller erwarteten Cache-Keys const expectedKeys = new Set(); - let created = 0; - let skipped = 0; - let failed = 0; + const toProcess: string[] = []; for (const s of allSounds) { const fp = path.join(SOUNDS_DIR, s.relativePath); const key = normCacheKey(fp); expectedKeys.add(key); if (!fs.existsSync(fp)) continue; - if (getNormCachePath(fp)) { skipped++; continue; } // bereits gecacht & gültig - try { - await normalizeToCache(fp); - created++; - } catch (e) { - failed++; - console.warn(`Norm-cache failed: ${s.relativePath}`, e); + if (getNormCachePath(fp)) continue; // bereits gecacht & gültig + toProcess.push(fp); + } + + let created = 0; + let failed = 0; + const skipped = allSounds.length - toProcess.length; + + // Parallele Worker-Pool: NORM_CONCURRENCY ffmpeg-Prozesse gleichzeitig + const queue = [...toProcess]; + async function worker(): Promise { + while (queue.length > 0) { + const fp = queue.shift()!; + try { + await normalizeToCache(fp); + created++; + } catch (e) { + failed++; + console.warn(`Norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); + } } } + const workers = Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker); + await Promise.all(workers); // Verwaiste Cache-Dateien aufräumen let cleaned = 0; @@ -237,7 +255,7 @@ async function syncNormCache(): Promise { const dt = ((Date.now() - t0) / 1000).toFixed(1); console.log( - `Norm-Cache sync (${dt}s): ${created} neu, ${skipped} vorhanden, ${failed} fehlgeschlagen, ${cleaned} verwaist entfernt (${allSounds.length} Sounds gesamt)` + `Norm-Cache sync (${dt}s, ${NORM_CONCURRENCY} parallel): ${created} neu, ${skipped} vorhanden, ${failed} fehlgeschlagen, ${cleaned} verwaist entfernt (${allSounds.length} Sounds gesamt)` ); } From a61663166f028b0ba828cd2bf73df93e90e52d50 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:30:37 +0100 Subject: [PATCH 20/22] Config: NORM_CONCURRENCY Default auf 2 (i5/i7 Zielplattform) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard von 4 auf 2 gesenkt – passend für i5-13400 (10-Kern Target). Lässt mehr Headroom für Discord-Wiedergabe und Node.js-Event-Loop. Per NORM_CONCURRENCY env var weiterhin anpassbar. Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 101cfbc..777444e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -194,9 +194,10 @@ function normalizeToCache(filePath: string): Promise { } // Wie viele ffmpeg-Prozesse parallel beim Cache-Sync laufen dürfen. -// Standard: 4 (gut für 4+ Kern CPUs ohne Discord-Wiedergabe zu stören). -// Über NORM_CONCURRENCY=8 o.ä. erhöhbar. -const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 4)); +// Standard: 2 (konservativ – lässt genug Headroom für Discord-Wiedergabe und Node.js). +// Empfehlung: i5/i7 (8-16 Kerne) → 2-3, Ryzen 9 / Xeon → 4-8 +// Über NORM_CONCURRENCY=4 env var erhöhbar. +const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 2)); /** * Vollständige Cache-Synchronisation: From 52c86240af0fa22aa7305c8b2dc6e53eaef47230 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 22:15:07 +0100 Subject: [PATCH 21/22] Feat: Drag & Drop MP3/WAV Upload mit Progress-Tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - multer reaktiviert (war auskommentiert) mit diskStorage + Collision-Handling - /api/upload (POST, admin-protected): bis zu 20 Dateien gleichzeitig - MP3/WAV-Filter (50MB Limit), sofortige Hintergrund-Normalisierung nach Upload Frontend: - Globale window dragenter/dragleave/drop Listener mit Counter gegen false-positives - Drag-Overlay: Vollbild-Blur + animierter Drop-Zone (pulsierender Accent-Border, bouncing Icon) - Upload-Queue: floating Card bottom-right mit Per-Datei Progressbar + Status-Icons (sync-Animation beim Hochladen, check_circle grün, error rot) - Auto-Refresh der Soundliste + Analytics nach Upload - Auto-Dismiss der Queue nach 3.5s Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 43 ++++++++- web/src/App.tsx | 151 +++++++++++++++++++++++++++++++- web/src/api.ts | 29 ++++++- web/src/styles.css | 206 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 777444e..11b1133 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import express, { Request, Response } from 'express'; -// import multer from 'multer'; +import multer from 'multer'; import cors from 'cors'; import crypto from 'node:crypto'; import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; @@ -1029,6 +1029,47 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) } }); +// --- Datei-Upload (Drag & Drop) --- +const uploadStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, SOUNDS_DIR), + filename: (_req, file, cb) => { + const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); + const { name, ext } = path.parse(safe); + let finalName = safe; + let i = 2; + while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { + finalName = `${name}-${i}${ext}`; + i++; + } + cb(null, finalName); + }, +}); +const uploadMulter = multer({ + storage: uploadStorage, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, ext === '.mp3' || ext === '.wav'); + }, + limits: { fileSize: 50 * 1024 * 1024, files: 20 }, +}); + +app.post('/api/upload', requireAdmin, (req: Request, res: Response) => { + uploadMulter.array('files', 20)(req, res, async (err) => { + if (err) return res.status(400).json({ error: err.message ?? 'Upload fehlgeschlagen' }); + const files = (req as any).files as Express.Multer.File[] | undefined; + if (!files?.length) return res.status(400).json({ error: 'Keine gültigen Dateien (nur MP3/WAV)' }); + const saved = files.map(f => ({ name: f.filename, size: f.size })); + // Normalisierung im Hintergrund starten + if (NORMALIZE_ENABLE) { + for (const f of files) { + normalizeToCache(f.path).catch(e => console.warn(`Norm after upload failed (${f.filename}):`, e)); + } + } + console.log(`${new Date().toISOString()} | Upload: ${files.map(f => f.filename).join(', ')}`); + res.json({ ok: true, files: saved }); + }); +}); + // --- Kategorien API --- app.get('/api/categories', (_req: Request, res: Response) => { res.json({ categories: persistedState.categories ?? [] }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 5bd90c5..dd144bb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,7 @@ import { fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, - getSelectedChannels, setSelectedChannel, + getSelectedChannels, setSelectedChannel, uploadFile, } from './api'; import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; import { getCookie, setCookie } from './cookies'; @@ -24,6 +24,15 @@ const CAT_PALETTE = [ type Tab = 'all' | 'favorites' | 'recent'; +type UploadItem = { + id: string; + file: File; + status: 'waiting' | 'uploading' | 'done' | 'error'; + progress: number; + savedName?: string; + error?: string; +}; + export default function App() { /* ── Data ── */ const [sounds, setSounds] = useState([]); @@ -75,6 +84,13 @@ export default function App() { const [renameTarget, setRenameTarget] = useState(''); const [renameValue, setRenameValue] = useState(''); + /* ── Drag & Drop Upload ── */ + const [isDragging, setIsDragging] = useState(false); + const [uploads, setUploads] = useState([]); + const [showUploads, setShowUploads] = useState(false); + const dragCounterRef = useRef(0); + const uploadDismissRef = useRef>(); + /* ── UI ── */ const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); const [clock, setClock] = useState(''); @@ -85,6 +101,41 @@ export default function App() { useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); useEffect(() => { selectedRef.current = selected; }, [selected]); + /* ── Drag & Drop: globale Window-Listener ── */ + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { + dragCounterRef.current++; + setIsDragging(true); + } + }; + const onDragLeave = () => { + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }; + const onDragOver = (e: DragEvent) => e.preventDefault(); + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setIsDragging(false); + const files = Array.from(e.dataTransfer?.files ?? []).filter(f => + /\.(mp3|wav)$/i.test(f.name) + ); + if (files.length) handleFileDrop(files); + }; + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin]); + /* ── Helpers ── */ const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { setNotification({ msg, type }); @@ -287,6 +338,52 @@ export default function App() { } } + async function handleFileDrop(files: File[]) { + if (!isAdmin) { + notify('Admin-Login erforderlich zum Hochladen', 'error'); + return; + } + if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); + + const items: UploadItem[] = files.map(f => ({ + id: Math.random().toString(36).slice(2), + file: f, + status: 'waiting', + progress: 0, + })); + setUploads(items); + setShowUploads(true); + + const updated = [...items]; + for (let i = 0; i < updated.length; i++) { + updated[i] = { ...updated[i], status: 'uploading' }; + setUploads([...updated]); + try { + const savedName = await uploadFile( + updated[i].file, + pct => { + updated[i] = { ...updated[i], progress: pct }; + setUploads([...updated]); + }, + ); + updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; + } catch (e: any) { + updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; + } + setUploads([...updated]); + } + + // Sound-Liste aktualisieren + setRefreshKey(k => k + 1); + void loadAnalytics(); + + // Auto-Dismiss nach 3s + uploadDismissRef.current = setTimeout(() => { + setShowUploads(false); + setUploads([]); + }, 3500); + } + async function handleStop() { if (!selected) return; setLastPlayed(''); @@ -1013,6 +1110,58 @@ export default function App() {
)} + + {/* ── Drag & Drop Overlay ── */} + {isDragging && ( +
+
+ cloud_upload +
MP3 & WAV hier ablegen
+
Mehrere Dateien gleichzeitig möglich
+
+
+ )} + + {/* ── Upload-Queue ── */} + {showUploads && uploads.length > 0 && ( +
+
+ upload + + {uploads.every(u => u.status === 'done' || u.status === 'error') + ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` + : `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`} + + +
+
+ {uploads.map(u => ( +
+ audio_file +
+
+ {u.savedName ?? u.file.name} +
+
{(u.file.size / 1024).toFixed(0)} KB
+
+ {(u.status === 'waiting' || u.status === 'uploading') && ( +
+
+
+ )} + + {u.status === 'done' ? 'check_circle' : + u.status === 'error' ? 'error' : + u.status === 'uploading' ? 'sync' : 'schedule'} + + {u.status === 'error' &&
{u.error}
} +
+ ))} +
+
+ )}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 4b6b0fd..4cf8736 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -206,7 +206,34 @@ export async function playUrl(url: string, guildId: string, channelId: string, v } } -// uploadFile removed (build reverted) +/** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */ +export function uploadFile( + file: File, + onProgress: (pct: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const form = new FormData(); + form.append('files', file); + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/upload`); + xhr.upload.onprogress = e => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => { + if (xhr.status === 200) { + try { + const data = JSON.parse(xhr.responseText); + resolve(data.files?.[0]?.name ?? file.name); + } catch { resolve(file.name); } + } else { + try { reject(new Error(JSON.parse(xhr.responseText).error)); } + catch { reject(new Error(`HTTP ${xhr.status}`)); } + } + }; + xhr.onerror = () => reject(new Error('Netzwerkfehler')); + xhr.send(form); + }); +} diff --git a/web/src/styles.css b/web/src/styles.css index 66abf75..4fa09d6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1778,6 +1778,212 @@ input, select { } } +/* ──────────────────────────────────────────── + Drag & Drop Overlay + ──────────────────────────────────────────── */ +.drop-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .78); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 120ms ease; + pointer-events: none; +} + +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 64px 72px; + border-radius: 24px; + border: 2.5px dashed rgba(var(--accent-rgb), .55); + background: rgba(var(--accent-rgb), .07); + animation: drop-pulse 2.2s ease-in-out infinite; +} + +@keyframes drop-pulse { + 0%, 100% { + border-color: rgba(var(--accent-rgb), .45); + box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); + } + 50% { + border-color: rgba(var(--accent-rgb), .9); + box-shadow: 0 0 60px 12px rgba(var(--accent-rgb), .12); + } +} + +.drop-icon { + font-size: 64px; + color: var(--accent); + animation: drop-bounce 1.8s ease-in-out infinite; +} + +@keyframes drop-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +.drop-title { + font-size: 22px; + font-weight: 700; + color: var(--text-normal); + letter-spacing: -.3px; +} + +.drop-sub { + font-size: 13px; + color: var(--text-muted); +} + +/* ──────────────────────────────────────────── + Upload Queue (floating card) + ──────────────────────────────────────────── */ +.upload-queue { + position: fixed; + bottom: 24px; + right: 24px; + width: 340px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .09); + border-radius: 14px; + box-shadow: 0 8px 40px rgba(0, 0, 0, .45); + z-index: 200; + overflow: hidden; + animation: slide-up 200ms cubic-bezier(.16,1,.3,1); +} + +@keyframes slide-up { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +.uq-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + background: rgba(var(--accent-rgb), .12); + border-bottom: 1px solid rgba(255, 255, 255, .06); + font-size: 13px; + font-weight: 600; + color: var(--text-normal); +} + +.uq-header .material-icons { color: var(--accent); } + +.uq-close { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,.06); + color: var(--text-muted); + cursor: pointer; + transition: background var(--transition), color var(--transition); +} +.uq-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); } + +.uq-list { + display: flex; + flex-direction: column; + max-height: 260px; + overflow-y: auto; + padding: 6px 0; +} + +.uq-item { + display: grid; + grid-template-columns: 20px 1fr auto 18px; + align-items: center; + gap: 8px; + padding: 8px 14px; + position: relative; +} + +.uq-item + .uq-item { + border-top: 1px solid rgba(255, 255, 255, .04); +} + +.uq-file-icon { + font-size: 18px; + color: var(--text-faint); +} + +.uq-info { + min-width: 0; +} + +.uq-name { + font-size: 12px; + font-weight: 500; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.uq-size { + font-size: 10px; + color: var(--text-faint); + margin-top: 1px; +} + +.uq-progress-wrap { + grid-column: 1 / -1; + height: 3px; + background: rgba(255, 255, 255, .07); + border-radius: 2px; + overflow: hidden; + margin-top: 4px; +} + +/* Vertikaler layout-Trick: progress bar als extra row nach den anderen */ +.uq-item { + flex-wrap: wrap; +} + +.uq-progress-wrap { + width: 100%; + order: 10; +} + +.uq-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 120ms ease; +} + +.uq-status-icon { font-size: 16px; } +.uq-status-waiting .uq-status-icon { color: var(--text-faint); } +.uq-status-uploading .uq-status-icon { + color: var(--accent); + animation: spin 1s linear infinite; +} +.uq-status-done .uq-status-icon { color: var(--green); } +.uq-status-error .uq-status-icon { color: var(--red); } + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.uq-error { + grid-column: 2 / -1; + font-size: 10px; + color: var(--red); + margin-top: 2px; +} + /* ──────────────────────────────────────────── Utility ──────────────────────────────────────────── */ From db9a18b367d98c3df9bb6a276711fba982db9da1 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 22:25:10 +0100 Subject: [PATCH 22/22] Fix: TypeScript-Fehler im multer Upload-Endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @types/multer zu devDependencies hinzugefügt (fehlte → TS7016) - Explizite Callback-Typen in diskStorage/fileFilter (TS7006) - Express.Multer.File durch lokalen MulterFile-Type ersetzt (TS2694) Co-Authored-By: Claude Opus 4.6 --- server/package.json | 1 + server/src/index.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/server/package.json b/server/package.json index cf39bef..e02373e 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", "@types/node": "^20.12.12", "ts-node": "^10.9.2", "typescript": "^5.5.4" diff --git a/server/src/index.ts b/server/src/index.ts index 11b1133..2016f8a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1030,9 +1030,11 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) }); // --- Datei-Upload (Drag & Drop) --- +type MulterFile = { fieldname: string; originalname: string; encoding: string; mimetype: string; destination: string; filename: string; path: string; size: number; }; + const uploadStorage = multer.diskStorage({ - destination: (_req, _file, cb) => cb(null, SOUNDS_DIR), - filename: (_req, file, cb) => { + destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR), + filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => { const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); const { name, ext } = path.parse(safe); let finalName = safe; @@ -1046,7 +1048,7 @@ const uploadStorage = multer.diskStorage({ }); const uploadMulter = multer({ storage: uploadStorage, - fileFilter: (_req, file, cb) => { + fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, ext === '.mp3' || ext === '.wav'); }, @@ -1054,9 +1056,9 @@ const uploadMulter = multer({ }); app.post('/api/upload', requireAdmin, (req: Request, res: Response) => { - uploadMulter.array('files', 20)(req, res, async (err) => { - if (err) return res.status(400).json({ error: err.message ?? 'Upload fehlgeschlagen' }); - const files = (req as any).files as Express.Multer.File[] | undefined; + uploadMulter.array('files', 20)(req, res, async (err: any) => { + if (err) return res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); + const files = (req as any).files as MulterFile[] | undefined; if (!files?.length) return res.status(400).json({ error: 'Keine gültigen Dateien (nur MP3/WAV)' }); const saved = files.map(f => ({ name: f.filename, size: f.size })); // Normalisierung im Hintergrund starten