From 3d1a6ca60b63dd3c505f3dbff2c3e39efadf8200 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Sat, 9 Aug 2025 17:16:37 +0200 Subject: [PATCH] =?UTF-8?q?Nightly:=20Kategorien=20eingef=C3=BChrt=20=20Pe?= =?UTF-8?q?rsistenz=20(state.json),=20API=20(CRUD=20+=20Bulk-Assign),=20So?= =?UTF-8?q?unds-Filter=20unterst=C3=BCtzt=20categoryId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/index.ts | 106 +++++++++++++++++++++++++++++++++++++++++--- web/src/api.ts | 28 +++++++++++- web/src/types.ts | 4 ++ 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 8abbf67..d59b241 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -44,8 +44,15 @@ if (!DISCORD_TOKEN) { fs.mkdirSync(SOUNDS_DIR, { recursive: true }); -// Persistente Lautstärke und Play-Zähler speichern -type PersistedState = { volumes: Record; plays: Record; totalPlays: number }; +// Persistenter Zustand: Lautstärke/Plays + Kategorien +type Category = { id: string; name: string; color?: string; sort?: number }; +type PersistedState = { + volumes: Record; + plays: Record; + totalPlays: number; + categories?: Category[]; + fileCategories?: Record; // relPath or fileName -> categoryIds[] +}; // Neuer, persistenter Speicherort direkt im Sounds-Volume const STATE_FILE_NEW = path.join(SOUNDS_DIR, 'state.json'); // Alter Speicherort (eine Ebene über SOUNDS_DIR). Wird für Migration gelesen, falls vorhanden. @@ -57,13 +64,25 @@ function readPersistedState(): PersistedState { if (fs.existsSync(STATE_FILE_NEW)) { const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); const parsed = JSON.parse(raw); - return { volumes: parsed.volumes ?? {}, plays: parsed.plays ?? {}, totalPlays: parsed.totalPlays ?? 0 } as PersistedState; + return { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {} + } as PersistedState; } // 2) Fallback: alten Speicherort lesen und sofort nach NEW migrieren if (fs.existsSync(STATE_FILE_OLD)) { const raw = fs.readFileSync(STATE_FILE_OLD, 'utf8'); const parsed = JSON.parse(raw); - const migrated: PersistedState = { volumes: parsed.volumes ?? {}, plays: parsed.plays ?? {}, totalPlays: parsed.totalPlays ?? 0 }; + const migrated: PersistedState = { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {} + }; try { fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); @@ -333,7 +352,7 @@ app.use(express.json()); app.use(cors()); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0 }); + res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length }); }); // --- Admin Auth --- @@ -397,6 +416,7 @@ app.get('/api/admin/status', (req: Request, res: Response) => { 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 rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); const rootFiles = rootEntries @@ -472,7 +492,16 @@ app.get('/api/sounds', (req: Request, res: Response) => { ...folders ]; // isRecent-Flag für UI (Top 5 der neuesten) + // Kategorie-Filter (virtuell) anwenden, wenn gesetzt let result = filteredItems; + if (categoryFilter) { + const fc = persistedState.fileCategories ?? {}; + result = result.filter((it) => { + const key = it.relativePath ?? it.fileName; + const cats = fc[key] ?? []; + return cats.includes(categoryFilter); + }); + } if (folderFilter === '__top3__') { const keys = new Set(top3.map(t => t.key.split(':')[1])); result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); @@ -483,7 +512,7 @@ app.get('/api/sounds', (req: Request, res: Response) => { isRecent: recentTop5Set.has(it.relativePath ?? it.fileName) })); - res.json({ items: withRecentFlag, total, folders: foldersOut }); + res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); }); // --- Admin: Bulk-Delete --- @@ -528,6 +557,71 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) } }); +// --- Kategorien API --- +app.get('/api/categories', (_req: Request, res: Response) => { + res.json({ categories: persistedState.categories ?? [] }); +}); + +app.post('/api/categories', requireAdmin, (req: Request, res: Response) => { + const { name, color, sort } = req.body as { name?: string; color?: string; sort?: number }; + const n = (name || '').trim(); + if (!n) return res.status(400).json({ error: 'name erforderlich' }); + const id = crypto.randomUUID(); + const cat = { id, name: n, color, sort }; + persistedState.categories = [...(persistedState.categories ?? []), cat]; + writePersistedState(persistedState); + res.json({ ok: true, category: cat }); +}); + +app.patch('/api/categories/:id', requireAdmin, (req: Request, res: Response) => { + const { id } = req.params; + const { name, color, sort } = req.body as { name?: string; color?: string; sort?: number }; + const cats = persistedState.categories ?? []; + const idx = cats.findIndex(c => c.id === id); + if (idx === -1) return res.status(404).json({ error: 'Kategorie nicht gefunden' }); + const updated = { ...cats[idx] } as any; + if (typeof name === 'string') updated.name = name; + if (typeof color === 'string') updated.color = color; + if (typeof sort === 'number') updated.sort = sort; + cats[idx] = updated; + persistedState.categories = cats; + writePersistedState(persistedState); + res.json({ ok: true, category: updated }); +}); + +app.delete('/api/categories/:id', requireAdmin, (req: Request, res: Response) => { + const { id } = req.params; + const cats = persistedState.categories ?? []; + if (!cats.find(c => c.id === id)) return res.status(404).json({ error: 'Kategorie nicht gefunden' }); + persistedState.categories = cats.filter(c => c.id !== id); + // Zuordnungen entfernen + const fc = persistedState.fileCategories ?? {}; + for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== id); + persistedState.fileCategories = fc; + writePersistedState(persistedState); + res.json({ ok: true }); +}); + +// Bulk-Assign/Remove Kategorien zu Dateien +app.post('/api/categories/assign', requireAdmin, (req: Request, res: Response) => { + const { files, add, remove } = req.body as { files?: string[]; add?: string[]; remove?: string[] }; + if (!Array.isArray(files) || files.length === 0) return res.status(400).json({ error: 'files[] erforderlich' }); + const validCats = new Set((persistedState.categories ?? []).map(c => c.id)); + const toAdd = (add ?? []).filter(id => validCats.has(id)); + const toRemove = (remove ?? []).filter(id => validCats.has(id)); + const fc = persistedState.fileCategories ?? {}; + for (const rel of files) { + const key = rel; + const old = new Set(fc[key] ?? []); + for (const a of toAdd) old.add(a); + for (const r of toRemove) old.delete(r); + fc[key] = Array.from(old); + } + persistedState.fileCategories = fc; + writePersistedState(persistedState); + res.json({ ok: true, fileCategories: fc }); +}); + app.get('/api/channels', (_req: Request, res: Response) => { if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' }); diff --git a/web/src/api.ts b/web/src/api.ts index c1853a1..b31a51f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2,15 +2,41 @@ import type { Sound, SoundsResponse, VoiceChannelInfo } from './types'; const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; -export async function fetchSounds(q?: string, folderKey?: string): Promise { +export async function fetchSounds(q?: string, folderKey?: string, categoryId?: string): Promise { const url = new URL(`${API_BASE}/sounds`, window.location.origin); if (q) url.searchParams.set('q', q); if (folderKey !== undefined) url.searchParams.set('folder', folderKey); + if (categoryId) url.searchParams.set('categoryId', categoryId); const res = await fetch(url.toString()); if (!res.ok) throw new Error('Fehler beim Laden der Sounds'); return res.json(); } +// Kategorien +export async function fetchCategories() { + const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' }); + if (!res.ok) throw new Error('Fehler beim Laden der Kategorien'); + return res.json(); +} + +export async function createCategory(name: string, color?: string) { + const res = await fetch(`${API_BASE}/categories`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ name, color }) + }); + if (!res.ok) throw new Error('Kategorie anlegen fehlgeschlagen'); + return res.json(); +} + +export async function assignCategories(files: string[], add: string[], remove: string[] = []) { + const res = await fetch(`${API_BASE}/categories/assign`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ files, add, remove }) + }); + if (!res.ok) throw new Error('Zuordnung fehlgeschlagen'); + return res.json(); +} + export async function fetchChannels(): Promise { const res = await fetch(`${API_BASE}/channels`); if (!res.ok) throw new Error('Fehler beim Laden der Channels'); diff --git a/web/src/types.ts b/web/src/types.ts index bb134c6..757daac 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -10,6 +10,8 @@ export type SoundsResponse = { items: Sound[]; total: number; folders: Array<{ key: string; name: string; count: number }>; + categories?: Category[]; + fileCategories?: Record; }; export type VoiceChannelInfo = { @@ -19,6 +21,8 @@ export type VoiceChannelInfo = { channelName: string; }; +export type Category = { id: string; name: string; color?: string; sort?: number }; +