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) {