([]);
- const [adminLoading, setAdminLoading] = useState(false);
- const [adminError, setAdminError] = useState('');
+ // ── Admin (centralized in App.tsx) ──
+ const _isAdmin = isAdminProp ?? false;
+ void _isAdmin;
// ── SSE data sync ──
useEffect(() => {
@@ -133,76 +129,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
} catch { /* silent */ }
}, []);
- // ── Admin: check login status on mount ──
- useEffect(() => {
- fetch('/api/game-library/admin/status', { credentials: 'include' })
- .then(r => r.json())
- .then(d => setIsAdmin(d.admin === true))
- .catch(() => {});
- }, []);
-
- // ── Admin: login ──
- const adminLogin = useCallback(async () => {
- setAdminError('');
- try {
- const resp = await fetch('/api/game-library/admin/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ password: adminPwd }),
- credentials: 'include',
- });
- if (resp.ok) {
- setIsAdmin(true);
- setAdminPwd('');
- } else {
- const d = await resp.json();
- setAdminError(d.error || 'Fehler');
- }
- } catch {
- setAdminError('Verbindung fehlgeschlagen');
- }
- }, [adminPwd]);
-
- // ── Admin: logout ──
- const adminLogout = useCallback(async () => {
- await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' });
- setIsAdmin(false);
- setShowAdmin(false);
- }, []);
-
- // ── Admin: load profiles ──
- const loadAdminProfiles = useCallback(async () => {
- setAdminLoading(true);
- try {
- const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' });
- if (resp.ok) {
- const d = await resp.json();
- setAdminProfiles(d.profiles || []);
- }
- } catch { /* silent */ }
- finally { setAdminLoading(false); }
- }, []);
-
- // ── Admin: open panel ──
- const openAdmin = useCallback(() => {
- setShowAdmin(true);
- if (isAdmin) loadAdminProfiles();
- }, [isAdmin, loadAdminProfiles]);
-
- // ── Admin: delete profile ──
- const adminDeleteProfile = useCallback(async (profileId: string, displayName: string) => {
- if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return;
- try {
- const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, {
- method: 'DELETE',
- credentials: 'include',
- });
- if (resp.ok) {
- loadAdminProfiles();
- fetchProfiles();
- }
- } catch { /* silent */ }
- }, [loadAdminProfiles, fetchProfiles]);
// ── Steam login ──
const connectSteam = useCallback(() => {
@@ -552,9 +478,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
)}
-
{/* ── Profile Chips ── */}
@@ -981,74 +904,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
);
})()}
- {/* ── Admin Panel ── */}
- {showAdmin && (
- setShowAdmin(false)}>
-
e.stopPropagation()}>
-
-
⚙️ Game Library Admin
-
-
-
- {!isAdmin ? (
-
-
Admin-Passwort eingeben:
-
- setAdminPwd(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
- autoFocus
- />
-
-
- {adminError &&
{adminError}
}
-
- ) : (
-
-
- ✅ Eingeloggt als Admin
-
-
-
-
- {adminLoading ? (
-
Lade Profile...
- ) : adminProfiles.length === 0 ? (
-
Keine Profile vorhanden.
- ) : (
-
- {adminProfiles.map((p: any) => (
-
-

-
- {p.displayName}
-
- {p.steamName && Steam: {p.steamGames}}
- {p.gogName && GOG: {p.gogGames}}
- {p.totalGames} Spiele
-
-
-
-
- ))}
-
- )}
-
- )}
-
-
- )}
-
{/* ── GOG Code Dialog (browser fallback only) ── */}
{gogDialogOpen && (
setGogDialogOpen(false)}>
diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx
index 064951b..a58c165 100644
--- a/web/src/plugins/soundboard/SoundboardTab.tsx
+++ b/web/src/plugins/soundboard/SoundboardTab.tsx
@@ -186,25 +186,6 @@ async function apiGetVolume(guildId: string): Promise {
return typeof data?.volume === 'number' ? data.volume : 1;
}
-async function apiAdminStatus(): Promise {
- const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' });
- if (!res.ok) return false;
- const data = await res.json();
- return !!data?.authenticated;
-}
-
-async function apiAdminLogin(password: string): Promise {
- const res = await fetch(`${API_BASE}/admin/login`, {
- method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
- body: JSON.stringify({ password })
- });
- return res.ok;
-}
-
-async function apiAdminLogout(): Promise {
- await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' });
-}
-
async function apiAdminDelete(paths: string[]): Promise {
const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
@@ -324,13 +305,14 @@ interface VoiceStats {
interface SoundboardTabProps {
data: any;
+ isAdmin?: boolean;
}
/* ══════════════════════════════════════════════════════════════════
COMPONENT
══════════════════════════════════════════════════════════════════ */
-export default function SoundboardTab({ data }: SoundboardTabProps) {
+export default function SoundboardTab({ data, isAdmin: isAdminProp }: SoundboardTabProps) {
/* ── Data ── */
const [sounds, setSounds] = useState([]);
const [total, setTotal] = useState(0);
@@ -378,15 +360,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
const volDebounceRef = useRef>(undefined);
/* ── Admin ── */
- 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('');
+ const isAdmin = isAdminProp ?? false;
/* ── Drag & Drop Upload ── */
const [isDragging, setIsDragging] = useState(false);
@@ -521,7 +495,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
}
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
- try { setIsAdmin(await apiAdminStatus()); } catch { }
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -656,13 +629,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
return () => document.removeEventListener('click', handler);
}, []);
- useEffect(() => {
- if (showAdmin && isAdmin) {
- void loadAdminSounds();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [showAdmin, isAdmin]);
-
/* ── Actions ── */
async function loadAnalytics() {
try {
@@ -821,86 +787,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
setFavs(prev => ({ ...prev, [key]: !prev[key] }));
}
- async function loadAdminSounds() {
- setAdminLoading(true);
- try {
- const d = await fetchSounds('', '__all__', undefined, false);
- setAdminSounds(d.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 gueltigen Namen eingeben', 'error');
- return;
- }
- try {
- await apiAdminRename(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 apiAdminDelete(paths);
- notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`);
- setAdminSelection({});
- cancelRename();
- setRefreshKey(k => k + 1);
- if (showAdmin) await loadAdminSounds();
- } catch (e: any) {
- notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
- }
- }
-
- async function handleAdminLogin() {
- try {
- const ok = await apiAdminLogin(adminPwd);
- if (ok) {
- setIsAdmin(true);
- setAdminPwd('');
- notify('Admin eingeloggt');
- }
- else notify('Falsches Passwort', 'error');
- } catch { notify('Login fehlgeschlagen', 'error'); }
- }
-
- async function handleAdminLogout() {
- try {
- await apiAdminLogout();
- setIsAdmin(false);
- setAdminSelection({});
- cancelRename();
- notify('Ausgeloggt');
- } catch { }
- }
-
/* ── Computed ── */
const displaySounds = useMemo(() => {
if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
@@ -938,26 +824,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
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 analyticsTop = analytics.mostPlayed.slice(0, 10);
const totalSoundsDisplay = analytics.totalSounds || total;
@@ -1040,13 +906,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)}
)}
-
@@ -1150,7 +1009,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
volDebounceRef.current = setTimeout(() => {
apiSetVolumeLive(guildId, v).catch(() => {});
- }, 120);
+ }, 50);
}
}}
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
@@ -1356,7 +1215,14 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
{
const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName;
- await deleteAdminPaths([path]);
+ if (!window.confirm(`Sound "${ctxMenu.sound.name}" loeschen?`)) { setCtxMenu(null); return; }
+ try {
+ await apiAdminDelete([path]);
+ notify('Sound geloescht');
+ setRefreshKey(k => k + 1);
+ } catch (e: any) {
+ notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
+ }
setCtxMenu(null);
}}>
delete
@@ -1437,159 +1303,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)}
- {/* ═══ ADMIN PANEL ═══ */}
- {showAdmin && (
- { if (e.target === e.currentTarget) setShowAdmin(false); }}>
-
-
- Admin
-
-
- {!isAdmin ? (
-
-
-
- setAdminPwd(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
- placeholder="Admin-Passwort..."
- />
-
-
-
- ) : (
-
-
-
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'}
- {' \u00B7 '}
- {key}
-
- {editing && (
-
- setRenameValue(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter') void submitRename();
- if (e.key === 'Escape') cancelRename();
- }}
- placeholder="Neuer Name..."
- />
-
-
-
- )}
-
-
- {!editing && (
-
-
-
-
- )}
-
- );
- })}
-
- )}
-
-
- )}
-
-
- )}
-
{/* ── Drag & Drop Overlay ── */}
{isDragging && (
diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx
index cd85e68..a511798 100644
--- a/web/src/plugins/streaming/StreamingTab.tsx
+++ b/web/src/plugins/streaming/StreamingTab.tsx
@@ -46,23 +46,22 @@ function formatElapsed(startedAt: string): string {
// ── Quality Presets ──
const QUALITY_PRESETS = [
- { label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 },
- { label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 },
- { label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 },
- { label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 },
- { label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 },
- { label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 },
+ { label: 'Niedrig \u00B7 4 Mbit \u00B7 60fps', fps: 60, bitrate: 4_000_000 },
+ { label: 'Mittel \u00B7 8 Mbit \u00B7 60fps', fps: 60, bitrate: 8_000_000 },
+ { label: 'Hoch \u00B7 14 Mbit \u00B7 60fps', fps: 60, bitrate: 14_000_000 },
+ { label: 'Ultra \u00B7 25 Mbit \u00B7 60fps', fps: 60, bitrate: 25_000_000 },
+ { label: 'Max \u00B7 50 Mbit \u00B7 165fps', fps: 165, bitrate: 50_000_000 },
] as const;
// ── Component ──
-export default function StreamingTab({ data }: { data: any }) {
+export default function StreamingTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
// ── State ──
const [streams, setStreams] = useState
([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
const [streamTitle, setStreamTitle] = useState('Screen Share');
const [streamPassword, setStreamPassword] = useState('');
- const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60
+ const [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60
const [error, setError] = useState(null);
const [joinModal, setJoinModal] = useState(null);
const [myStreamId, setMyStreamId] = useState(null);
@@ -73,16 +72,9 @@ export default function StreamingTab({ data }: { data: any }) {
const [openMenu, setOpenMenu] = useState(null);
const [copiedId, setCopiedId] = useState(null);
- // ── Admin / Notification Config ──
- const [showAdmin, setShowAdmin] = useState(false);
- const [isAdmin, setIsAdmin] = useState(false);
- const [adminPwd, setAdminPwd] = useState('');
- const [adminError, setAdminError] = useState('');
- const [availableChannels, setAvailableChannels] = useState>([]);
- const [notifyConfig, setNotifyConfig] = useState>([]);
- const [configLoading, setConfigLoading] = useState(false);
- const [configSaving, setConfigSaving] = useState(false);
- const [notifyStatus, setNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null });
+ // ── Admin ──
+ const _isAdmin = isAdminProp ?? false;
+ void _isAdmin; // kept for potential future use
// ── Refs ──
const wsRef = useRef(null);
@@ -100,7 +92,7 @@ export default function StreamingTab({ data }: { data: any }) {
// Refs that mirror state (avoid stale closures in WS handler)
const isBroadcastingRef = useRef(false);
const viewingRef = useRef(null);
- const qualityRef = useRef(QUALITY_PRESETS[2]);
+ const qualityRef = useRef(QUALITY_PRESETS[1]);
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
useEffect(() => { viewingRef.current = viewing; }, [viewing]);
useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]);
@@ -138,17 +130,6 @@ export default function StreamingTab({ data }: { data: any }) {
return () => document.removeEventListener('click', handler);
}, [openMenu]);
- // Check admin status on mount
- useEffect(() => {
- fetch('/api/notifications/admin/status', { credentials: 'include' })
- .then(r => r.json())
- .then(d => setIsAdmin(d.admin === true))
- .catch(() => {});
- fetch('/api/notifications/status')
- .then(r => r.json())
- .then(d => setNotifyStatus(d))
- .catch(() => {});
- }, []);
// ── Send via WS ──
const wsSend = useCallback((d: Record) => {
@@ -422,7 +403,7 @@ export default function StreamingTab({ data }: { data: any }) {
try {
const q = qualityRef.current;
const stream = await navigator.mediaDevices.getDisplayMedia({
- video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } },
+ video: { frameRate: { ideal: q.fps } },
audio: true,
});
localStreamRef.current = stream;
@@ -610,97 +591,6 @@ export default function StreamingTab({ data }: { data: any }) {
setOpenMenu(null);
}, [buildStreamLink]);
- // ── Admin functions ──
- const adminLogin = useCallback(async () => {
- setAdminError('');
- try {
- const resp = await fetch('/api/notifications/admin/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ password: adminPwd }),
- credentials: 'include',
- });
- if (resp.ok) {
- setIsAdmin(true);
- setAdminPwd('');
- loadNotifyConfig();
- } else {
- const d = await resp.json();
- setAdminError(d.error || 'Fehler');
- }
- } catch {
- setAdminError('Verbindung fehlgeschlagen');
- }
- }, [adminPwd]);
-
- const adminLogout = useCallback(async () => {
- await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' });
- setIsAdmin(false);
- setShowAdmin(false);
- }, []);
-
- const loadNotifyConfig = useCallback(async () => {
- setConfigLoading(true);
- try {
- const [chResp, cfgResp] = await Promise.all([
- fetch('/api/notifications/channels', { credentials: 'include' }),
- fetch('/api/notifications/config', { credentials: 'include' }),
- ]);
- if (chResp.ok) {
- const chData = await chResp.json();
- setAvailableChannels(chData.channels || []);
- }
- if (cfgResp.ok) {
- const cfgData = await cfgResp.json();
- setNotifyConfig(cfgData.channels || []);
- }
- } catch { /* silent */ }
- finally { setConfigLoading(false); }
- }, []);
-
- const openAdmin = useCallback(() => {
- setShowAdmin(true);
- if (isAdmin) loadNotifyConfig();
- }, [isAdmin, loadNotifyConfig]);
-
- const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
- setNotifyConfig(prev => {
- const existing = prev.find(c => c.channelId === channelId);
- if (existing) {
- const hasEvent = existing.events.includes(event);
- const newEvents = hasEvent
- ? existing.events.filter(e => e !== event)
- : [...existing.events, event];
- if (newEvents.length === 0) {
- return prev.filter(c => c.channelId !== channelId);
- }
- return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c);
- } else {
- return [...prev, { channelId, channelName, guildId, guildName, events: [event] }];
- }
- });
- }, []);
-
- const saveNotifyConfig = useCallback(async () => {
- setConfigSaving(true);
- try {
- const resp = await fetch('/api/notifications/config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ channels: notifyConfig }),
- credentials: 'include',
- });
- if (resp.ok) {
- // brief visual feedback handled by configSaving state
- }
- } catch { /* silent */ }
- finally { setConfigSaving(false); }
- }, [notifyConfig]);
-
- const isChannelEventEnabled = useCallback((channelId: string, event: string): boolean => {
- const ch = notifyConfig.find(c => c.channelId === channelId);
- return ch?.events.includes(event) ?? false;
- }, [notifyConfig]);
// ── Render ──
@@ -754,39 +644,50 @@ export default function StreamingTab({ data }: { data: any }) {
)}
- setUserName(e.target.value)}
- disabled={isBroadcasting}
- />
- setStreamTitle(e.target.value)}
- disabled={isBroadcasting}
- />
- setStreamPassword(e.target.value)}
- disabled={isBroadcasting}
- />
-
+
+
+
+
{isBroadcasting ? (
)}
-
{streams.length === 0 && !isBroadcasting ? (
@@ -903,100 +801,6 @@ export default function StreamingTab({ data }: { data: any }) {
)}
- {/* ── Notification Admin Modal ── */}
- {showAdmin && (
- setShowAdmin(false)}>
-
e.stopPropagation()}>
-
-
{'\uD83D\uDD14'} Benachrichtigungen
-
-
-
- {!isAdmin ? (
-
-
Admin-Passwort eingeben:
-
- setAdminPwd(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
- autoFocus
- />
-
-
- {adminError &&
{adminError}
}
-
- ) : (
-
-
-
- {notifyStatus.online
- ? <>{'\u2705'} Bot online: {notifyStatus.botTag}>
- : <>{'\u26A0\uFE0F'} Bot offline — DISCORD_TOKEN_NOTIFICATIONS setzen>}
-
-
-
-
- {configLoading ? (
-
Lade Kan{'\u00E4'}le...
- ) : availableChannels.length === 0 ? (
-
- {notifyStatus.online
- ? 'Keine Text-Kan\u00E4le gefunden. Bot hat m\u00F6glicherweise keinen Zugriff.'
- : 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'}
-
- ) : (
- <>
-
- W{'\u00E4'}hle die Kan{'\u00E4'}le, in die Benachrichtigungen gesendet werden sollen:
-
-
- {availableChannels.map(ch => (
-
- ))}
-
-
-
-
- >
- )}
-
- )}
-
-
- )}
);
}
diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css
index 9d43c2e..ada73b0 100644
--- a/web/src/plugins/streaming/streaming.css
+++ b/web/src/plugins/streaming/streaming.css
@@ -9,12 +9,27 @@
/* ── Top Bar ── */
.stream-topbar {
display: flex;
- align-items: center;
+ align-items: flex-end;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
+.stream-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.stream-field-grow { flex: 1; min-width: 180px; }
+.stream-field-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-faint);
+ padding-left: 2px;
+}
+
.stream-input {
padding: 10px 14px;
border: 1px solid var(--bg-tertiary);
@@ -25,11 +40,13 @@
outline: none;
transition: border-color var(--transition);
min-width: 0;
+ width: 100%;
+ box-sizing: border-box;
}
.stream-input:focus { border-color: var(--accent); }
.stream-input::placeholder { color: var(--text-faint); }
.stream-input-name { width: 150px; }
-.stream-input-title { flex: 1; min-width: 180px; }
+.stream-input-title { width: 100%; }
.stream-btn {
padding: 10px 20px;
@@ -405,11 +422,12 @@
/* ── Password input in topbar ── */
.stream-input-password {
- width: 140px;
+ width: 180px;
}
.stream-select-quality {
- width: 120px;
+ width: 210px;
+ box-sizing: border-box;
padding: 10px 14px;
border: 1px solid var(--bg-tertiary);
border-radius: var(--radius);
diff --git a/web/src/styles.css b/web/src/styles.css
index 656b9d5..3b1c050 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -353,6 +353,104 @@ html, body {
background: rgba(230, 126, 34, 0.1);
}
+/* ── Admin Button (header) ── */
+.hub-admin-btn {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-muted);
+ font-size: 16px;
+ padding: 4px 8px;
+ cursor: pointer;
+ transition: all var(--transition);
+ line-height: 1;
+}
+.hub-admin-btn:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+.hub-admin-btn.active {
+ color: #4ade80;
+ border-color: #4ade80;
+}
+
+/* ── Admin Login Modal ── */
+.hub-admin-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+}
+.hub-admin-modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 340px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+.hub-admin-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+}
+.hub-admin-modal-close {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+}
+.hub-admin-modal-close:hover {
+ color: var(--text);
+}
+.hub-admin-modal-body {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.hub-admin-input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ color: var(--text);
+ font-size: 14px;
+ font-family: var(--font);
+ box-sizing: border-box;
+}
+.hub-admin-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.hub-admin-error {
+ color: #ef4444;
+ font-size: 13px;
+ margin: 0;
+}
+.hub-admin-submit {
+ padding: 8px 16px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity var(--transition);
+}
+.hub-admin-submit:hover {
+ opacity: 0.9;
+}
+
/* ── Version Info Modal ── */
.hub-version-clickable {
cursor: pointer;
@@ -1578,3 +1676,1233 @@ html, body {
border-radius: 50%;
flex-shrink: 0;
}
+
+/* ══════════════════════════════════════════════
+ UNIFIED ADMIN PANEL (ap-*)
+ ══════════════════════════════════════════════ */
+
+.ap-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 9998;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(6px);
+ animation: fade-in 150ms ease;
+}
+
+.ap-modal {
+ display: flex;
+ width: min(940px, calc(100vw - 40px));
+ height: min(620px, calc(100vh - 60px));
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ overflow: hidden;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
+ animation: hub-modal-in 200ms ease;
+ position: relative;
+}
+
+/* ── Sidebar ── */
+.ap-sidebar {
+ width: 210px;
+ min-width: 210px;
+ background: var(--bg-deep);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+}
+
+.ap-sidebar-title {
+ padding: 18px 20px 14px;
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-normal);
+ letter-spacing: -0.01em;
+ border-bottom: 1px solid var(--border);
+}
+
+.ap-nav {
+ display: flex;
+ flex-direction: column;
+ padding: 8px;
+ gap: 2px;
+}
+
+.ap-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border: none;
+ border-left: 3px solid transparent;
+ background: transparent;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ border-radius: 0 var(--radius) var(--radius) 0;
+ transition: all var(--transition);
+ text-align: left;
+}
+
+.ap-nav-item:hover {
+ color: var(--text-normal);
+ background: var(--bg-secondary);
+}
+
+.ap-nav-item.active {
+ color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.1);
+ border-left-color: var(--accent);
+ font-weight: 600;
+}
+
+.ap-nav-icon {
+ font-size: 16px;
+ line-height: 1;
+}
+
+.ap-nav-label {
+ line-height: 1;
+}
+
+.ap-logout-btn {
+ margin-top: auto;
+ padding: 10px 16px;
+ background: transparent;
+ border: none;
+ border-top: 1px solid var(--border);
+ color: #e74c3c;
+ font-size: 0.85rem;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.15s;
+}
+.ap-logout-btn:hover {
+ background: rgba(231, 76, 60, 0.1);
+}
+
+/* ── Content Area ── */
+.ap-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.ap-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.ap-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-normal);
+ margin: 0;
+}
+
+.ap-close {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 6px;
+ transition: all var(--transition);
+}
+
+.ap-close:hover {
+ color: var(--text-normal);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.ap-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 20px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--bg-tertiary) transparent;
+}
+
+.ap-body::-webkit-scrollbar {
+ width: 6px;
+}
+.ap-body::-webkit-scrollbar-thumb {
+ background: var(--bg-tertiary);
+ border-radius: 3px;
+}
+
+.ap-tab-content {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ animation: fade-in 150ms ease;
+}
+
+/* ── Toolbar ── */
+.ap-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.ap-search {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ color: var(--text-normal);
+ font-size: 13px;
+ font-family: var(--font);
+}
+
+.ap-search:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.ap-search::placeholder {
+ color: var(--text-faint);
+}
+
+/* ── Buttons ── */
+.ap-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 14px;
+ border: none;
+ border-radius: var(--radius);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+
+.ap-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.ap-btn-primary {
+ background: var(--accent);
+ color: #fff;
+}
+.ap-btn-primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+.ap-btn-danger {
+ background: rgba(237, 66, 69, 0.15);
+ color: var(--danger);
+ border: 1px solid rgba(237, 66, 69, 0.3);
+}
+.ap-btn-danger:hover:not(:disabled) {
+ background: rgba(237, 66, 69, 0.25);
+}
+
+.ap-btn-outline {
+ background: var(--bg-secondary);
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+}
+.ap-btn-outline:hover:not(:disabled) {
+ color: var(--text-normal);
+ border-color: var(--text-faint);
+}
+
+.ap-btn-sm {
+ padding: 5px 10px;
+ font-size: 12px;
+}
+
+/* ── Upload Zone ── */
+.ap-upload-zone {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 14px;
+ border: 2px dashed var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ cursor: pointer;
+ transition: all var(--transition);
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+.ap-upload-zone:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.05);
+}
+
+.ap-upload-progress {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+/* ── Bulk Row ── */
+.ap-bulk-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 0;
+}
+
+.ap-select-all {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.ap-select-all input[type="checkbox"] {
+ accent-color: var(--accent);
+}
+
+/* ── Sound List ── */
+.ap-list-wrap {
+ flex: 1;
+ min-height: 0;
+}
+
+.ap-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ap-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ transition: background var(--transition);
+}
+
+.ap-item:hover {
+ background: var(--bg-tertiary);
+}
+
+.ap-item-check {
+ flex-shrink: 0;
+ cursor: pointer;
+}
+
+.ap-item-check input[type="checkbox"] {
+ accent-color: var(--accent);
+}
+
+.ap-item-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ap-item-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-normal);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ap-item-meta {
+ font-size: 11px;
+ color: var(--text-faint);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ap-item-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.ap-rename-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.ap-rename-input {
+ flex: 1;
+ padding: 5px 8px;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ background: var(--bg-deep);
+ color: var(--text-normal);
+ font-size: 12px;
+ font-family: var(--font);
+}
+
+.ap-rename-input:focus {
+ outline: none;
+}
+
+/* ── Empty ── */
+.ap-empty {
+ text-align: center;
+ color: var(--text-muted);
+ padding: 40px 16px;
+ font-size: 13px;
+}
+
+/* ── Hint ── */
+.ap-hint {
+ font-size: 13px;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+/* ── Status Badge ── */
+.ap-status-badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.ap-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--danger);
+ flex-shrink: 0;
+}
+
+.ap-status-dot.online {
+ background: var(--success);
+ animation: pulse-dot 2s ease-in-out infinite;
+}
+
+/* ── Channel List (Streaming) ── */
+.ap-channel-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ap-channel-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ transition: background var(--transition);
+}
+
+.ap-channel-row:hover {
+ background: var(--bg-tertiary);
+}
+
+.ap-channel-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.ap-channel-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-normal);
+}
+
+.ap-channel-guild {
+ font-size: 11px;
+ color: var(--text-faint);
+}
+
+.ap-channel-toggles {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.ap-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: var(--radius);
+ transition: all var(--transition);
+}
+
+.ap-toggle input[type="checkbox"] {
+ accent-color: var(--accent);
+}
+
+.ap-toggle.active {
+ color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.08);
+}
+
+/* ── Save Row ── */
+.ap-save-row {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 8px;
+}
+
+/* ── Profile List (Game Library) ── */
+.ap-profile-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ap-profile-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ transition: background var(--transition);
+}
+
+.ap-profile-row:hover {
+ background: var(--bg-tertiary);
+}
+
+.ap-profile-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.ap-profile-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ap-profile-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-normal);
+}
+
+.ap-profile-details {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+}
+
+.ap-platform-badge {
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: 600;
+ font-size: 10px;
+}
+
+.ap-platform-badge.steam {
+ background: rgba(66, 133, 244, 0.15);
+ color: #64b5f6;
+}
+
+.ap-platform-badge.gog {
+ background: rgba(171, 71, 188, 0.15);
+ color: #ce93d8;
+}
+
+.ap-profile-total {
+ color: var(--text-faint);
+}
+
+/* ── Toast ── */
+.ap-toast {
+ position: absolute;
+ bottom: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 8px 16px;
+ border-radius: var(--radius);
+ font-size: 13px;
+ font-weight: 500;
+ background: var(--bg-tertiary);
+ color: var(--text-normal);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ animation: fade-in 150ms ease;
+ z-index: 10;
+}
+
+.ap-toast.error {
+ background: rgba(237, 66, 69, 0.2);
+ color: #f87171;
+}
+
+/* ── Admin Panel Responsive ── */
+@media (max-width: 768px) {
+ .ap-modal {
+ flex-direction: column;
+ width: calc(100vw - 16px);
+ height: calc(100vh - 32px);
+ }
+
+ .ap-sidebar {
+ width: 100%;
+ min-width: 100%;
+ flex-direction: row;
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ overflow-x: auto;
+ }
+
+ .ap-sidebar-title {
+ display: none;
+ }
+
+ .ap-nav {
+ flex-direction: row;
+ padding: 4px 8px;
+ gap: 4px;
+ }
+
+ .ap-nav-item {
+ border-left: none;
+ border-bottom: 3px solid transparent;
+ border-radius: var(--radius) var(--radius) 0 0;
+ padding: 8px 12px;
+ white-space: nowrap;
+ }
+
+ .ap-nav-item.active {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent);
+ }
+
+ .ap-channel-toggles {
+ flex-direction: column;
+ gap: 4px;
+ }
+}
+
+/* ════════════════════════════════════════════════════════════════════════════
+ Unified Login Button (Header)
+ ════════════════════════════════════════════════════════════════════════════ */
+.hub-user-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: transparent;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition);
+ line-height: 1;
+ white-space: nowrap;
+}
+.hub-user-btn:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.06);
+}
+.hub-user-btn.logged-in {
+ border-color: rgba(var(--accent-rgb), 0.3);
+ color: var(--text-normal);
+}
+.hub-user-btn.admin {
+ border-color: #4ade80;
+ color: #4ade80;
+}
+.hub-user-avatar {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+.hub-user-icon {
+ font-size: 16px;
+ line-height: 1;
+}
+.hub-user-label {
+ line-height: 1;
+}
+
+/* ════════════════════════════════════════════════════════════════════════════
+ Login Modal
+ ════════════════════════════════════════════════════════════════════════════ */
+.hub-login-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+}
+.hub-login-modal {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ width: 380px;
+ max-width: 92vw;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
+ overflow: hidden;
+ animation: hub-modal-in 200ms ease;
+}
+.hub-login-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 700;
+ font-size: 15px;
+}
+.hub-login-modal-close {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 4px;
+ border-radius: 4px;
+ transition: all var(--transition);
+}
+.hub-login-modal-close:hover {
+ color: var(--text-normal);
+ background: var(--bg-tertiary);
+}
+.hub-login-modal-body {
+ padding: 20px;
+}
+.hub-login-subtitle {
+ color: var(--text-muted);
+ font-size: 13px;
+ margin: 0 0 16px;
+ line-height: 1.4;
+}
+
+/* Provider Buttons */
+.hub-login-providers {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.hub-login-provider-btn {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--bg-secondary);
+ color: var(--text-normal);
+ font-family: var(--font);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition);
+ text-decoration: none;
+ position: relative;
+}
+.hub-login-provider-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.06);
+ transform: translateY(-1px);
+}
+.hub-login-provider-btn:active:not(:disabled) {
+ transform: translateY(0);
+}
+.hub-login-provider-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.hub-login-provider-btn.discord:hover:not(:disabled) {
+ border-color: #5865F2;
+ background: rgba(88, 101, 242, 0.08);
+}
+.hub-login-provider-btn.steam:hover {
+ border-color: #66c0f4;
+ background: rgba(102, 192, 244, 0.08);
+}
+.hub-login-provider-btn.admin:hover {
+ border-color: var(--accent);
+}
+.hub-login-provider-icon {
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+}
+.hub-login-provider-icon-emoji {
+ font-size: 20px;
+ line-height: 1;
+ flex-shrink: 0;
+}
+.hub-login-soon {
+ position: absolute;
+ right: 12px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-faint);
+ background: var(--bg-tertiary);
+ padding: 2px 6px;
+ border-radius: 4px;
+ letter-spacing: 0.5px;
+}
+.hub-login-hint {
+ margin: 16px 0 0;
+ font-size: 12px;
+ color: var(--text-faint);
+ line-height: 1.4;
+}
+.hub-login-back {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: 13px;
+ cursor: pointer;
+ padding: 0;
+ margin-bottom: 16px;
+ transition: color var(--transition);
+}
+.hub-login-back:hover {
+ color: var(--accent);
+}
+
+/* Admin form inside login modal */
+.hub-login-admin-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.hub-login-admin-label {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-normal);
+}
+.hub-login-admin-input {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ color: var(--text-normal);
+ font-size: 14px;
+ font-family: var(--font);
+ box-sizing: border-box;
+ transition: border-color var(--transition);
+}
+.hub-login-admin-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.hub-login-admin-error {
+ color: var(--danger);
+ font-size: 13px;
+ margin: 0;
+}
+.hub-login-admin-submit {
+ padding: 10px 16px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 14px;
+ font-weight: 600;
+ font-family: var(--font);
+ cursor: pointer;
+ transition: opacity var(--transition);
+}
+.hub-login-admin-submit:hover:not(:disabled) {
+ opacity: 0.9;
+}
+.hub-login-admin-submit:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ════════════════════════════════════════════════════════════════════════════
+ User Settings Panel
+ ════════════════════════════════════════════════════════════════════════════ */
+.hub-usettings-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+}
+.hub-usettings-panel {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ width: 520px;
+ max-width: 95vw;
+ max-height: 85vh;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ animation: hub-modal-in 200ms ease;
+}
+
+/* Header */
+.hub-usettings-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.hub-usettings-user {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.hub-usettings-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid rgba(var(--accent-rgb), 0.3);
+}
+.hub-usettings-avatar-placeholder {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--accent);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ font-weight: 700;
+}
+.hub-usettings-user-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.hub-usettings-username {
+ font-weight: 600;
+ font-size: 15px;
+ color: var(--text-normal);
+}
+.hub-usettings-discriminator {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.hub-usettings-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.hub-usettings-logout {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-muted);
+ font-size: 16px;
+ padding: 6px 8px;
+ cursor: pointer;
+ transition: all var(--transition);
+ line-height: 1;
+}
+.hub-usettings-logout:hover {
+ color: var(--danger);
+ border-color: var(--danger);
+}
+.hub-usettings-close {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 4px;
+ transition: all var(--transition);
+}
+.hub-usettings-close:hover {
+ color: var(--text-normal);
+ background: var(--bg-tertiary);
+}
+
+/* Toast */
+.hub-usettings-toast {
+ padding: 8px 16px;
+ font-size: 13px;
+ text-align: center;
+ animation: hub-toast-in 300ms ease;
+}
+.hub-usettings-toast.success {
+ background: rgba(87, 210, 143, 0.1);
+ color: var(--success);
+}
+.hub-usettings-toast.error {
+ background: rgba(237, 66, 69, 0.1);
+ color: var(--danger);
+}
+@keyframes hub-toast-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Loading */
+.hub-usettings-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 40px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+/* Content */
+.hub-usettings-content {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ flex: 1;
+}
+
+/* Section tabs */
+.hub-usettings-tabs {
+ display: flex;
+ gap: 4px;
+ padding: 12px 20px 0;
+ flex-shrink: 0;
+}
+.hub-usettings-tab {
+ flex: 1;
+ padding: 10px 12px;
+ border: none;
+ background: var(--bg-secondary);
+ color: var(--text-muted);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ border-radius: var(--radius) var(--radius) 0 0;
+ transition: all var(--transition);
+}
+.hub-usettings-tab:hover {
+ color: var(--text-normal);
+ background: var(--bg-tertiary);
+}
+.hub-usettings-tab.active {
+ color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.1);
+ border-bottom: 2px solid var(--accent);
+}
+
+/* Current sound */
+.hub-usettings-current {
+ padding: 12px 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ background: var(--bg-secondary);
+ margin: 0 20px;
+ border-radius: 0 0 var(--radius) var(--radius);
+ margin-bottom: 12px;
+}
+.hub-usettings-current-label {
+ font-size: 13px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+.hub-usettings-current-value {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--accent);
+}
+.hub-usettings-current-none {
+ font-size: 13px;
+ color: var(--text-faint);
+ font-style: italic;
+}
+.hub-usettings-remove-btn {
+ background: none;
+ border: none;
+ color: var(--text-faint);
+ cursor: pointer;
+ font-size: 11px;
+ padding: 2px 4px;
+ border-radius: 4px;
+ transition: all var(--transition);
+}
+.hub-usettings-remove-btn:hover:not(:disabled) {
+ color: var(--danger);
+ background: rgba(237, 66, 69, 0.1);
+}
+
+/* Search */
+.hub-usettings-search-wrap {
+ position: relative;
+ padding: 0 20px;
+ margin-bottom: 12px;
+ flex-shrink: 0;
+}
+.hub-usettings-search {
+ width: 100%;
+ padding: 8px 32px 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ color: var(--text-normal);
+ font-size: 13px;
+ font-family: var(--font);
+ box-sizing: border-box;
+ transition: border-color var(--transition);
+}
+.hub-usettings-search:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.hub-usettings-search-clear {
+ position: absolute;
+ right: 28px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: var(--text-faint);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 4px;
+}
+.hub-usettings-search-clear:hover {
+ color: var(--text-normal);
+}
+
+/* Sound list */
+.hub-usettings-sounds {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 20px 16px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--bg-tertiary) transparent;
+}
+.hub-usettings-empty {
+ text-align: center;
+ padding: 32px;
+ color: var(--text-faint);
+ font-size: 14px;
+}
+
+/* Folder */
+.hub-usettings-folder {
+ margin-bottom: 12px;
+}
+.hub-usettings-folder-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 4px 0;
+ margin-bottom: 6px;
+ border-bottom: 1px solid var(--border);
+}
+.hub-usettings-folder-sounds {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+/* Sound button */
+.hub-usettings-sound-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-secondary);
+ color: var(--text-normal);
+ font-family: var(--font);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+.hub-usettings-sound-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.06);
+}
+.hub-usettings-sound-btn.selected {
+ border-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.12);
+ color: var(--accent);
+}
+.hub-usettings-sound-btn:disabled {
+ opacity: 0.5;
+ cursor: wait;
+}
+.hub-usettings-sound-icon {
+ font-size: 12px;
+ line-height: 1;
+}
+.hub-usettings-sound-name {
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ── Mobile responsive for user settings ── */
+@media (max-width: 600px) {
+ .hub-usettings-panel {
+ width: 100%;
+ max-width: 100vw;
+ max-height: 100vh;
+ border-radius: 0;
+ }
+ .hub-user-label {
+ display: none;
+ }
+}