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[]; +}; +