From 9e7b572feb4122c1960cb30264c4c7c80b322071 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 01:47:17 +0200 Subject: [PATCH 01/10] Nightly: Drag & Drop Upload im Header (Admin) + Server-Upload-Endpoint (/api/upload, MP3/WAV) --- server/src/index.ts | 31 +++++++++++++++++++++++++++++++ web/src/App.tsx | 15 +++++++++++++-- web/src/api.ts | 16 ++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index df33d9d..9ec1249 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +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 cors from 'cors'; import crypto from 'node:crypto'; import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message } from 'discord.js'; @@ -1028,6 +1029,36 @@ app.post('/api/play-url', async (req: Request, res: Response) => { } }); +// --- Datei-Upload (Admin): MP3/WAV per HTTP hochladen --- +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } }); +app.post('/api/upload', requireAdmin, upload.single('file'), async (req: Request, res: Response) => { + try { + const file = (req as any).file as Express.Multer.File | undefined; + const folderRaw = String((req.body as any)?.folder ?? '').trim(); + if (!file) return res.status(400).json({ error: 'file erforderlich' }); + const orig = file.originalname || 'upload'; + const lower = orig.toLowerCase(); + const ext = lower.endsWith('.mp3') ? '.mp3' : lower.endsWith('.wav') ? '.wav' : ''; + if (!ext) return res.status(400).json({ error: 'Nur MP3 oder WAV erlaubt' }); + const base = path.parse(orig).name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); + const safeFolder = folderRaw && !folderRaw.includes('..') ? folderRaw : ''; + const targetDir = path.join(SOUNDS_DIR, safeFolder); + fs.mkdirSync(targetDir, { recursive: true }); + let targetPath = path.join(targetDir, `${base}${ext}`); + let i = 2; + while (fs.existsSync(targetPath)) { + targetPath = path.join(targetDir, `${base}-${i}${ext}`); + i += 1; + } + fs.writeFileSync(targetPath, file.buffer); + const rel = path.relative(SOUNDS_DIR, targetPath).replace(/\\/g, '/'); + return res.json({ ok: true, relativePath: rel, fileName: path.basename(targetPath) }); + } catch (e: any) { + console.error('upload 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 a7a2f90..bf7280b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ 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 } from './api'; +import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, uploadFile } from './api'; import type { VoiceChannelInfo, Sound, Category } from './types'; import { getCookie, setCookie } from './cookies'; @@ -299,7 +299,18 @@ export default function App() {
🥦
)} -
+
{ e.preventDefault(); }} onDrop={async (e)=>{ + try{ + e.preventDefault(); + if(!isAdmin){ setError('Login required for upload'); return; } + const files = Array.from(e.dataTransfer?.files || []).filter(f=>/\.(mp3|wav)$/i.test(f.name)); + if(files.length===0){ setInfo('Drop MP3/WAV files to upload'); return; } + for(const f of files){ await uploadFile(f); } + setInfo(`${files.length} file(s) uploaded`); + const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined); + setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders); + }catch(err:any){ setError(err?.message||'Upload failed'); setInfo(null); } + }}>

diff --git a/web/src/api.ts b/web/src/api.ts index 2e487a6..0b5edc3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -184,6 +184,22 @@ export async function playUrl(url: string, guildId: string, channelId: string, v } } +export async function uploadFile(file: File, folder?: string) { + const form = new FormData(); + form.append('file', file); + if (folder) form.append('folder', folder); + const res = await fetch(`${API_BASE}/upload`, { + method: 'POST', + credentials: 'include', + body: form + }); + if (!res.ok) { + const data = await res.json().catch(()=>({})); + throw new Error(data?.error || 'Upload failed'); + } + return res.json(); +} + From 6f51c493edaf0af2f7b6188c2313c0dbbcc8af5a Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 02:16:09 +0200 Subject: [PATCH 02/10] Nightly: Revert Drag & Drop Upload (server endpoint + header handlers removed) --- server/src/index.ts | 32 ++------------------------------ web/src/App.tsx | 15 ++------------- web/src/api.ts | 16 +--------------- 3 files changed, 5 insertions(+), 58 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 9ec1249..01d82dc 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 } from 'discord.js'; @@ -1029,35 +1029,7 @@ app.post('/api/play-url', async (req: Request, res: Response) => { } }); -// --- Datei-Upload (Admin): MP3/WAV per HTTP hochladen --- -const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } }); -app.post('/api/upload', requireAdmin, upload.single('file'), async (req: Request, res: Response) => { - try { - const file = (req as any).file as Express.Multer.File | undefined; - const folderRaw = String((req.body as any)?.folder ?? '').trim(); - if (!file) return res.status(400).json({ error: 'file erforderlich' }); - const orig = file.originalname || 'upload'; - const lower = orig.toLowerCase(); - const ext = lower.endsWith('.mp3') ? '.mp3' : lower.endsWith('.wav') ? '.wav' : ''; - if (!ext) return res.status(400).json({ error: 'Nur MP3 oder WAV erlaubt' }); - const base = path.parse(orig).name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); - const safeFolder = folderRaw && !folderRaw.includes('..') ? folderRaw : ''; - const targetDir = path.join(SOUNDS_DIR, safeFolder); - fs.mkdirSync(targetDir, { recursive: true }); - let targetPath = path.join(targetDir, `${base}${ext}`); - let i = 2; - while (fs.existsSync(targetPath)) { - targetPath = path.join(targetDir, `${base}-${i}${ext}`); - i += 1; - } - fs.writeFileSync(targetPath, file.buffer); - const rel = path.relative(SOUNDS_DIR, targetPath).replace(/\\/g, '/'); - return res.json({ ok: true, relativePath: rel, fileName: path.basename(targetPath) }); - } catch (e: any) { - console.error('upload error:', e); - return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); - } -}); +// Upload endpoint removed (build reverted) diff --git a/web/src/App.tsx b/web/src/App.tsx index bf7280b..a7a2f90 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ 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, uploadFile } from './api'; +import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents } from './api'; import type { VoiceChannelInfo, Sound, Category } from './types'; import { getCookie, setCookie } from './cookies'; @@ -299,18 +299,7 @@ export default function App() {
🥦
)} -
{ e.preventDefault(); }} onDrop={async (e)=>{ - try{ - e.preventDefault(); - if(!isAdmin){ setError('Login required for upload'); return; } - const files = Array.from(e.dataTransfer?.files || []).filter(f=>/\.(mp3|wav)$/i.test(f.name)); - if(files.length===0){ setInfo('Drop MP3/WAV files to upload'); return; } - for(const f of files){ await uploadFile(f); } - setInfo(`${files.length} file(s) uploaded`); - const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined); - setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders); - }catch(err:any){ setError(err?.message||'Upload failed'); setInfo(null); } - }}> +

diff --git a/web/src/api.ts b/web/src/api.ts index 0b5edc3..e650220 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -184,21 +184,7 @@ export async function playUrl(url: string, guildId: string, channelId: string, v } } -export async function uploadFile(file: File, folder?: string) { - const form = new FormData(); - form.append('file', file); - if (folder) form.append('folder', folder); - const res = await fetch(`${API_BASE}/upload`, { - method: 'POST', - credentials: 'include', - body: form - }); - if (!res.ok) { - const data = await res.json().catch(()=>({})); - throw new Error(data?.error || 'Upload failed'); - } - return res.json(); -} +// uploadFile removed (build reverted) From d97511476873cf25aceb90f06ce26b6f431d1f49 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 02:59:25 +0200 Subject: [PATCH 03/10] =?UTF-8?q?Feat:=20Serverseitige=20Fuzzy-Suche=20f?= =?UTF-8?q?=C3=BCr=20/api/sounds=20und=20Client-Filter=20vereinfacht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/index.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- web/src/App.tsx | 7 ++----- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 01d82dc..a44af00 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -485,7 +485,50 @@ app.get('/api/sounds', (req: Request, res: Response) => { itemsByFolder = allItems.filter((it) => (folderFilter === '' ? it.folder === '' : it.folder === folderFilter)); } } - const filteredItems = itemsByFolder.filter((s) => (q ? s.name.toLowerCase().includes(q) : true)); + // Fuzzy-Score: bevorzugt Präfixe, zusammenhängende Treffer und frühe Positionen + function fuzzyScore(text: string, pattern: string): number { + if (!pattern) return 1; + if (text === pattern) return 2000; + const idx = text.indexOf(pattern); + if (idx !== -1) { + let base = 1000; + if (idx === 0) base += 200; // Präfix-Bonus + return base - idx * 2; // leichte Positionsstrafe + } + // subsequence Matching + let textIndex = 0; + let patIndex = 0; + let score = 0; + let lastMatch = -1; + let gaps = 0; + let firstMatchPos = -1; + while (textIndex < text.length && patIndex < pattern.length) { + if (text[textIndex] === pattern[patIndex]) { + if (firstMatchPos === -1) firstMatchPos = textIndex; + if (lastMatch === textIndex - 1) { + score += 5; // zusammenhängende Treffer belohnen + } + lastMatch = textIndex; + patIndex++; + } else if (firstMatchPos !== -1) { + gaps++; + } + textIndex++; + } + if (patIndex !== pattern.length) return 0; // nicht alle Pattern-Zeichen gefunden + score += Math.max(0, 300 - firstMatchPos * 2); // frühe Starts belohnen + score += Math.max(0, 100 - gaps * 10); // weniger Lücken belohnen + return score; + } + + let filteredItems = itemsByFolder; + if (q) { + const scored = itemsByFolder + .map((it) => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) })) + .filter((x) => x.score > 0) + .sort((a, b) => (b.score - a.score) || a.it.name.localeCompare(b.it.name)); + filteredItems = scored.map((x) => x.it); + } const total = allItems.length; const recentCount = Math.min(10, total); diff --git a/web/src/App.tsx b/web/src/App.tsx index a7a2f90..699c01d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -186,11 +186,8 @@ export default function App() { })(); }, [selected]); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return sounds; - return sounds.filter((s) => s.name.toLowerCase().includes(q)); - }, [sounds, query]); + // Server liefert bereits gefilterte (und ggf. fuzzy-sortierte) Ergebnisse + const filtered = sounds; const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); From 62890e0e85dc0506145aea7ef578e672275813c0 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 17:51:07 +0200 Subject: [PATCH 04/10] =?UTF-8?q?Feat:=20Toggle=20f=C3=BCr=20Fuzzy-Suche?= =?UTF-8?q?=20(default=20OFF),=20Server=20respektiert=20=3Ffuzzy=3D1/0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/index.ts | 16 +++++++++++----- web/src/App.tsx | 20 ++++++++++++++------ web/src/api.ts | 3 ++- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index a44af00..84c34e3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -432,6 +432,8 @@ app.get('/api/sounds', (req: Request, res: Response) => { const q = String(req.query.q ?? '').toLowerCase(); const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; + 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 @@ -523,11 +525,15 @@ app.get('/api/sounds', (req: Request, res: Response) => { let filteredItems = itemsByFolder; if (q) { - const scored = itemsByFolder - .map((it) => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) })) - .filter((x) => x.score > 0) - .sort((a, b) => (b.score - a.score) || a.it.name.localeCompare(b.it.name)); - filteredItems = scored.map((x) => x.it); + if (useFuzzy) { + const scored = itemsByFolder + .map((it) => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) })) + .filter((x) => x.score > 0) + .sort((a, b) => (b.score - a.score) || a.it.name.localeCompare(b.it.name)); + filteredItems = scored.map((x) => x.it); + } else { + filteredItems = itemsByFolder.filter((s) => s.name.toLowerCase().includes(q)); + } } const total = allItems.length; diff --git a/web/src/App.tsx b/web/src/App.tsx index 699c01d..a10fc5e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ 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 [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -113,7 +114,7 @@ export default function App() { (async () => { try { const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder; - const s = await fetchSounds(query, folderParam, activeCategoryId || undefined); + const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, fuzzy); setSounds(s.items); setTotal(s.total); setFolders(s.folders); @@ -121,7 +122,7 @@ export default function App() { setError(e?.message || 'Fehler beim Laden der Sounds'); } })(); - }, [activeFolder, query, activeCategoryId]); + }, [activeFolder, query, activeCategoryId, fuzzy]); // Favoriten aus Cookie laden useEffect(() => { @@ -324,6 +325,13 @@ export default function App() { +

- setTheme(e.target.value)}> + + + palette unfold_more @@ -673,8 +673,14 @@ function CustomSelect({ channels, value, onChange }: SelectProps) { return (
- {open && typeof document !== 'undefined' && ReactDOM.createPortal( From fdf0dea3e6b79a37f5b1338b552ff266f69f1510 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 18:39:43 +0200 Subject: [PATCH 06/10] feat(ui): Fuzzy-Button neben Suche verschoben; Icon-only (blur_on) mit Tooltip und ARIA-Label --- web/src/App.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 58c517d..0aa2b2f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -325,13 +325,6 @@ export default function App() { - +
+ setQuery(e.target.value)} /> + search +
From e83954624c82324894a52b727bf3b349be234558 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 18:47:33 +0200 Subject: [PATCH 07/10] feat(channel): Serverweite Channel-Auswahl mit Persistenz und SSE-Broadcast; Frontend passt Auswahl global an --- server/src/index.ts | 65 ++++++++++++++++++++++++++++++++++++++++----- web/src/App.tsx | 44 ++++++++++++++++++++++++------ web/src/api.ts | 15 +++++++++++ web/src/types.ts | 1 + 4 files changed, 111 insertions(+), 14 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 84c34e3..90da983 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -47,13 +47,14 @@ fs.mkdirSync(SOUNDS_DIR, { recursive: true }); // Persistenter Zustand: Lautstärke/Plays + Kategorien type Category = { id: string; name: string; color?: string; sort?: number }; -type PersistedState = { + type PersistedState = { volumes: Record; plays: Record; totalPlays: number; categories?: Category[]; fileCategories?: Record; // relPath or fileName -> categoryIds[] fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) + selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) }; // Neuer, persistenter Speicherort direkt im Sounds-Volume const STATE_FILE_NEW = path.join(SOUNDS_DIR, 'state.json'); @@ -72,7 +73,8 @@ function readPersistedState(): PersistedState { totalPlays: parsed.totalPlays ?? 0, categories: Array.isArray(parsed.categories) ? parsed.categories : [], fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {} + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {} } as PersistedState; } // 2) Fallback: alten Speicherort lesen und sofort nach NEW migrieren @@ -85,7 +87,8 @@ function readPersistedState(): PersistedState { totalPlays: parsed.totalPlays ?? 0, categories: Array.isArray(parsed.categories) ? parsed.categories : [], fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {} + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {} }; try { fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); @@ -162,6 +165,23 @@ function sseBroadcast(payload: any) { } } +// Hilfsfunktionen für serverweit ausgewählten Channel pro Guild +function getSelectedChannelForGuild(guildId: string): string | undefined { + const id = String(guildId || ''); + if (!id) return undefined; + const sc = persistedState.selectedChannels ?? {}; + return sc[id]; +} +function setSelectedChannelForGuild(guildId: string, channelId: string): void { + const g = String(guildId || ''); + const c = String(channelId || ''); + if (!g || !c) return; + if (!persistedState.selectedChannels) persistedState.selectedChannels = {}; + persistedState.selectedChannels[g] = c; + writePersistedState(persistedState); + sseBroadcast({ type: 'channel', guildId: g, channelId: c }); +} + async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { const guild = client.guilds.cache.get(guildId); if (!guild) throw new Error('Guild nicht gefunden'); @@ -727,13 +747,14 @@ app.get('/api/channels', (_req: Request, res: Response) => { if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' }); const allowed = new Set(ALLOWED_GUILD_IDS); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string }> = []; + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; for (const [, guild] of client.guilds.cache) { if (allowed.size > 0 && !allowed.has(guild.id)) continue; const channels = guild.channels.cache; for (const [, ch] of channels) { if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name }); + const sel = getSelectedChannelForGuild(guild.id); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); } } } @@ -741,6 +762,36 @@ app.get('/api/channels', (_req: Request, res: Response) => { res.json(result); }); +// Globale Channel-Auswahl: auslesen (komplettes Mapping) +app.get('/api/selected-channels', (_req: Request, res: Response) => { + try { + res.json({ selected: persistedState.selectedChannels ?? {} }); + } catch (e: any) { + res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + +// Globale Channel-Auswahl: setzen (validiert Channel-Typ) +app.post('/api/selected-channel', async (req: Request, res: Response) => { + try { + const { guildId, channelId } = req.body as { guildId?: string; channelId?: string }; + const gid = String(guildId ?? ''); + const cid = String(channelId ?? ''); + if (!gid || !cid) return res.status(400).json({ error: 'guildId und channelId erforderlich' }); + const guild = client.guilds.cache.get(gid); + if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); + const ch = guild.channels.cache.get(cid); + if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { + return res.status(400).json({ error: 'Ungültiger Voice-Channel' }); + } + setSelectedChannelForGuild(gid, cid); + return res.json({ ok: true }); + } catch (e: any) { + console.error('selected-channel error', e); + return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + app.post('/api/play', async (req: Request, res: Response) => { try { const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { @@ -1023,7 +1074,9 @@ app.get('/api/events', (req: Request, res: Response) => { res.flushHeaders?.(); // Snapshot senden - try { res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive) })}\n\n`); } catch {} + try { + res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {} })}\n\n`); + } catch {} // Ping, damit Proxies die Verbindung offen halten const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); diff --git a/web/src/App.tsx b/web/src/App.tsx index 0aa2b2f..57fefeb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ 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 } from './api'; +import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel } from './api'; import type { VoiceChannelInfo, Sound, Category } from './types'; import { getCookie, setCookie } from './cookies'; @@ -59,14 +59,19 @@ export default function App() { useEffect(() => { (async () => { try { - const c = await fetchChannels(); + const [c, selectedMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); setChannels(c); - const stored = localStorage.getItem('selectedChannel'); - if (stored && c.find(x => `${x.guildId}:${x.channelId}` === stored)) { - setSelected(stored); - } else if (c[0]) { - setSelected(`${c[0].guildId}:${c[0].channelId}`); + 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}`; + } } + if (initial) setSelected(initial); } catch (e: any) { setError(e?.message || 'Fehler beim Laden der Channels'); } @@ -90,6 +95,23 @@ export default function App() { }); } else if (msg?.type === 'snapshot') { setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); + try { + const sel = msg?.selected || {}; + const gid = selected ? selected.split(':')[0] : ''; + if (gid && sel[gid]) { + const newVal = `${gid}:${sel[gid]}`; + setSelected(newVal); + } + } catch {} + } else if (msg?.type === 'channel') { + try { + const gid = msg.guildId; + const cid = msg.channelId; + if (gid && cid) { + const curGid = selected ? selected.split(':')[0] : ''; + if (curGid === gid) setSelected(`${gid}:${cid}`); + } + } catch {} } }); return () => { try { unsub(); } catch {} }; @@ -358,7 +380,13 @@ export default function App() {
- + { + setSelected(v); + try { + const [gid, cid] = v.split(':'); + await setSelectedChannel(gid, cid); + } catch (e) { /* noop */ } + }} /> folder_special
diff --git a/web/src/api.ts b/web/src/api.ts index 28fe3e8..cd61520 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -79,6 +79,21 @@ export async function fetchChannels(): Promise { return res.json(); } +export async function getSelectedChannels(): Promise> { + const res = await fetch(`${API_BASE}/selected-channels`); + if (!res.ok) throw new Error('Fehler beim Laden der Channel-Auswahl'); + const data = await res.json(); + return data?.selected || {}; +} + +export async function setSelectedChannel(guildId: string, channelId: string): Promise { + const res = await fetch(`${API_BASE}/selected-channel`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guildId, channelId }) + }); + if (!res.ok) throw new Error('Channel-Auswahl setzen fehlgeschlagen'); +} + export async function playSound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise { const res = await fetch(`${API_BASE}/play`, { method: 'POST', diff --git a/web/src/types.ts b/web/src/types.ts index 40ab488..4cfbef3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -20,6 +20,7 @@ export type VoiceChannelInfo = { guildName: string; channelId: string; channelName: string; + selected?: boolean; }; export type Category = { id: string; name: string; color?: string; sort?: number }; From c86f4f58755d0f078709705da0cab86ed60b855e Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 18:54:48 +0200 Subject: [PATCH 08/10] fix(sse): Live-Update der Channel-Auswahl in allen Tabs (Ref gegen stale closure) --- web/src/App.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 57fefeb..ac0b911 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ export default function App() { 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); @@ -55,6 +56,7 @@ export default function App() { const chaosTimeoutRef = useRef(null); const chaosModeRef = useRef(false); useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); + useEffect(() => { selectedRef.current = selected; }, [selected]); useEffect(() => { (async () => { @@ -97,7 +99,8 @@ export default function App() { setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); try { const sel = msg?.selected || {}; - const gid = selected ? selected.split(':')[0] : ''; + const currentSelected = selectedRef.current || ''; + const gid = currentSelected ? currentSelected.split(':')[0] : ''; if (gid && sel[gid]) { const newVal = `${gid}:${sel[gid]}`; setSelected(newVal); @@ -108,7 +111,8 @@ export default function App() { const gid = msg.guildId; const cid = msg.channelId; if (gid && cid) { - const curGid = selected ? selected.split(':')[0] : ''; + const currentSelected = selectedRef.current || ''; + const curGid = currentSelected ? currentSelected.split(':')[0] : ''; if (curGid === gid) setSelected(`${gid}:${cid}`); } } catch {} From 7a067aa95f88488779f0e46d9a42b17e07a4f4a5 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 19:43:09 +0200 Subject: [PATCH 09/10] feat(version): API /api/health liefert build/version; UI zeigt Server-Version im Nightly-Badge --- server/src/index.ts | 11 ++++++++++- web/src/App.tsx | 10 ++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 90da983..f11b806 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -37,6 +37,9 @@ const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean); +// Build/Version-Infos (zur Laufzeit verfĂĽgbar machen) +const BUILD_CHANNEL = String(process.env.VITE_BUILD_CHANNEL ?? process.env.BUILD_CHANNEL ?? 'stable'); +const APP_VERSION = String(process.env.VITE_APP_VERSION ?? process.env.APP_VERSION ?? '1.0.0'); if (!DISCORD_TOKEN) { console.error('Fehlende Umgebungsvariable DISCORD_TOKEN'); @@ -387,7 +390,13 @@ app.use(express.json()); app.use(cors()); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length }); + res.json({ + ok: true, + totalPlays: persistedState.totalPlays ?? 0, + categories: (persistedState.categories ?? []).length, + version: APP_VERSION, + build: BUILD_CHANNEL + }); }); // --- Admin Auth --- diff --git a/web/src/App.tsx b/web/src/App.tsx index ac0b911..4d067ec 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -53,6 +53,8 @@ export default function App() { const [totalPlays, setTotalPlays] = useState(0); const [mediaUrl, setMediaUrl] = useState(''); const [chaosMode, setChaosMode] = useState(false); + const [serverVersion, setServerVersion] = useState(''); + const [serverBuild, setServerBuild] = useState(''); const chaosTimeoutRef = useRef(null); const chaosModeRef = useRef(false); useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); @@ -81,7 +83,11 @@ export default function App() { 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); + if (h) { + if (typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); + if (typeof h.version === 'string') setServerVersion(h.version); + if (typeof h.build === 'string') setServerBuild(h.build); + } } catch {} })(); }, []); @@ -330,7 +336,7 @@ export default function App() { Jukebox 420 {import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
- v{import.meta.env.VITE_APP_VERSION || ''} + v{serverVersion || import.meta.env.VITE_APP_VERSION || ''} • Nightly
)} From dda30d48fa3ab7fd7314637365881ec7a46df5f3 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sun, 10 Aug 2025 19:49:35 +0200 Subject: [PATCH 10/10] chore(version): Entfernt serverseitige Versions-/Build-Logik; Nightly-Badge bleibt im Header --- server/src/index.ts | 11 +---------- web/src/App.tsx | 11 ++--------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index f11b806..90da983 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -37,9 +37,6 @@ const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean); -// Build/Version-Infos (zur Laufzeit verfĂĽgbar machen) -const BUILD_CHANNEL = String(process.env.VITE_BUILD_CHANNEL ?? process.env.BUILD_CHANNEL ?? 'stable'); -const APP_VERSION = String(process.env.VITE_APP_VERSION ?? process.env.APP_VERSION ?? '1.0.0'); if (!DISCORD_TOKEN) { console.error('Fehlende Umgebungsvariable DISCORD_TOKEN'); @@ -390,13 +387,7 @@ app.use(express.json()); app.use(cors()); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ - ok: true, - totalPlays: persistedState.totalPlays ?? 0, - categories: (persistedState.categories ?? []).length, - version: APP_VERSION, - build: BUILD_CHANNEL - }); + res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length }); }); // --- Admin Auth --- diff --git a/web/src/App.tsx b/web/src/App.tsx index 4d067ec..b58ef7f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -53,8 +53,6 @@ export default function App() { const [totalPlays, setTotalPlays] = useState(0); const [mediaUrl, setMediaUrl] = useState(''); const [chaosMode, setChaosMode] = useState(false); - const [serverVersion, setServerVersion] = useState(''); - const [serverBuild, setServerBuild] = useState(''); const chaosTimeoutRef = useRef(null); const chaosModeRef = useRef(false); useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); @@ -83,11 +81,7 @@ export default function App() { try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch {} try { const h = await fetch('/api/health').then(r => r.json()).catch(() => null); - if (h) { - if (typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); - if (typeof h.version === 'string') setServerVersion(h.version); - if (typeof h.build === 'string') setServerBuild(h.build); - } + if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays); } catch {} })(); }, []); @@ -334,9 +328,8 @@ export default function App() {

Jukebox 420 - {import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && ( + {import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
- v{serverVersion || import.meta.env.VITE_APP_VERSION || ''} • Nightly
)}