From f9bec8b5a1274955dd1d719623a10e6c2740f7fe Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Fri, 8 Aug 2025 01:56:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(folders):=20Tabs=20f=C3=BCr=20Unterordner?= =?UTF-8?q?=20+=20rekursive=20Sound-Liste;=20Play=20mit=20relativePath;=20?= =?UTF-8?q?UI-Tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/index.ts | 48 +++++++++++++++++++++++++++++++++++---------- web/src/App.tsx | 34 +++++++++++++++++++++++++++++--- web/src/api.ts | 6 +++--- web/src/styles.css | 10 ++++++++++ web/src/types.ts | 3 +++ 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 07c0e8d..50ad076 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -190,17 +190,40 @@ app.get('/api/health', (_req: Request, res: Response) => { app.get('/api/sounds', (req: Request, res: Response) => { const q = String(req.query.q ?? '').toLowerCase(); - const files = fs - .readdirSync(SOUNDS_DIR, { withFileTypes: true }) + + const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); + const rootFiles = rootEntries .filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.mp3')) - .map((d) => d.name) - .sort((a, b) => a.localeCompare(b)); + .map((d) => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name })); - const items = files - .map((file) => ({ fileName: file, name: path.parse(file).name })) - .filter((s) => (q ? s.name.toLowerCase().includes(q) : true)); + const folders: Array<{ key: string; name: string; count: number }> = []; - res.json({ items, total: files.length }); + 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 mp3s = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.mp3')); + for (const f of mp3s) { + 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: mp3s.length }); + } + + const allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name)); + const filteredItems = allItems.filter((s) => (q ? s.name.toLowerCase().includes(q) : true)); + + const total = allItems.length; + const rootCount = rootFiles.length; + const foldersOut = [{ key: '__all__', name: 'Alle', count: total }, { key: '', name: 'Root', count: rootCount }, ...folders]; + + res.json({ items: filteredItems, total, folders: foldersOut }); }); app.get('/api/channels', (_req: Request, res: Response) => { @@ -223,15 +246,20 @@ app.get('/api/channels', (_req: Request, res: Response) => { app.post('/api/play', async (req: Request, res: Response) => { try { - const { soundName, guildId, channelId, volume } = req.body as { + const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { soundName?: string; guildId?: string; channelId?: string; volume?: number; // 0..1 + folder?: string; // optional subfolder key + relativePath?: string; // optional direct relative path }; if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); - const filePath = path.join(SOUNDS_DIR, `${soundName}.mp3`); + let filePath: string; + if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); + else if (folder) filePath = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); + else filePath = path.join(SOUNDS_DIR, `${soundName}.mp3`); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); const guild = client.guilds.cache.get(guildId); diff --git a/web/src/App.tsx b/web/src/App.tsx index e0294e2..03ae007 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,8 @@ import type { VoiceChannelInfo, Sound } from './types'; export default function App() { const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); + const [folders, setFolders] = useState>([]); + const [activeFolder, setActiveFolder] = useState('__all__'); const [channels, setChannels] = useState([]); const [query, setQuery] = useState(''); const [selected, setSelected] = useState(''); @@ -18,6 +20,7 @@ export default function App() { const [s, c] = await Promise.all([fetchSounds(), fetchChannels()]); setSounds(s.items); setTotal(s.total); + setFolders(s.folders); setChannels(c); if (c[0]) setSelected(`${c[0].guildId}:${c[0].channelId}`); } catch (e: any) { @@ -30,6 +33,7 @@ export default function App() { const s = await fetchSounds(query); setSounds(s.items); setTotal(s.total); + setFolders(s.folders); } catch {} }, 10000); return () => clearInterval(interval); @@ -41,13 +45,13 @@ export default function App() { return sounds.filter((s) => s.name.toLowerCase().includes(q)); }, [sounds, query]); - async function handlePlay(name: string) { + async function handlePlay(name: string, rel?: string) { setError(null); if (!selected) return setError('Bitte einen Voice-Channel auswählen'); const [guildId, channelId] = selected.split(':'); try { setLoading(true); - await playSound(name, guildId, channelId, volume); + await playSound(name, guildId, channelId, volume, rel); } catch (e: any) { setError(e?.message || 'Play fehlgeschlagen'); } finally { @@ -101,11 +105,31 @@ export default function App() { + {folders.length > 0 && ( + + )} + {error &&
{error}
}
{filtered.map((s) => ( - ))} @@ -116,6 +140,10 @@ export default function App() { ); } +function handlePlayWithPathFactory(play: (name: string, rel?: string) => Promise) { + return (s: Sound & { relativePath?: string }) => play(s.name, s.relativePath); +} + diff --git a/web/src/api.ts b/web/src/api.ts index a9c8539..351cf2f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2,7 +2,7 @@ import type { Sound, SoundsResponse, VoiceChannelInfo } from './types'; const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; -export async function fetchSounds(q?: string): Promise { +export async function fetchSounds(q?: string, folderKey?: string): Promise { const url = new URL(`${API_BASE}/sounds`, window.location.origin); if (q) url.searchParams.set('q', q); const res = await fetch(url.toString()); @@ -16,11 +16,11 @@ export async function fetchChannels(): Promise { return res.json(); } -export async function playSound(soundName: string, guildId: string, channelId: string, volume: number): Promise { +export async function playSound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise { const res = await fetch(`${API_BASE}/play`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ soundName, guildId, channelId, volume }) + body: JSON.stringify({ soundName, guildId, channelId, volume, relativePath }) }); if (!res.ok) { const data = await res.json().catch(() => ({})); diff --git a/web/src/styles.css b/web/src/styles.css index 13d267e..de23099 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -55,6 +55,16 @@ header p { opacity: .8; } font-size: 14px; } +.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,.12); + background: rgba(18,24,48,.6); + color: #e7e7ee; +} +.tab.active { background: linear-gradient(135deg, rgba(88,28,135,.9), rgba(59,130,246,.9)); color: #fff; border-color: transparent; } + diff --git a/web/src/types.ts b/web/src/types.ts index 1ee73e5..04a5fca 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,11 +1,14 @@ export type Sound = { fileName: string; name: string; + folder?: string; + relativePath?: string; }; export type SoundsResponse = { items: Sound[]; total: number; + folders: Array<{ key: string; name: string; count: number }>; }; export type VoiceChannelInfo = {