feat: Discord OAuth Login + User Settings GUI
All checks were successful
Build & Deploy / build (push) Successful in 44s
Build & Deploy / deploy (push) Successful in 5s
Build & Deploy / bump-version (push) Successful in 2s

- Neues unified Login-Modal (Discord, Steam, Admin) ersetzt alten Admin-Login
- Discord OAuth2 Backend (server/src/core/discord-auth.ts)
- User Settings Panel: Entrance/Exit Sounds per Web-GUI konfigurierbar
- API-Endpoints: /api/soundboard/user/{sounds,entrance,exit}
- Session-Management via HMAC-signierte Cookies (hub_session)
- Steam-Button als Platzhalter (bald verfuegbar)
- Backward-kompatibel mit bestehendem Admin-Cookie

Benoetigte neue Env-Vars: DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
Discord Redirect URI: PUBLIC_URL/api/auth/discord/callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-10 20:41:16 +01:00
parent a7e8407996
commit 99d69f30ba
7 changed files with 1435 additions and 60 deletions

View file

@ -0,0 +1,247 @@
// ──────────────────────────────────────────────────────────────────────────────
// Discord OAuth2 Authentication + Unified Session Management
// ──────────────────────────────────────────────────────────────────────────────
import crypto from 'node:crypto';
import type express from 'express';
// ── Config ──
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? '';
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? '';
const DISCORD_API = 'https://discord.com/api/v10';
const DISCORD_AUTH_URL = 'https://discord.com/oauth2/authorize';
const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds
const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds
// ── Types ──
export interface DiscordUser {
id: string;
username: string;
discriminator: string;
avatar: string | null;
global_name: string | null;
}
export interface UserSession {
provider: 'discord' | 'admin';
discordId?: string;
username?: string;
avatar?: string | null;
globalName?: string | null;
iat: number;
exp: number;
}
// ── Helpers ──
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function readCookie(req: express.Request, key: string): string | undefined {
const c = req.headers.cookie;
if (!c) return undefined;
for (const part of c.split(';')) {
const [k, v] = part.trim().split('=');
if (k === key) return decodeURIComponent(v || '');
}
return undefined;
}
// ── Session Token (HMAC-SHA256) ──
// Uses ADMIN_PWD as base secret, with a salt to differentiate from admin tokens
const SESSION_SECRET = (process.env.ADMIN_PWD ?? '') + ':hub_session_v1';
export function signSession(session: UserSession): string {
const body = b64url(JSON.stringify(session));
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
export function verifySession(token: string | undefined): UserSession | null {
if (!token) return null;
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
if (expected !== sig) return null;
try {
const session = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as UserSession;
if (typeof session.exp === 'number' && Date.now() < session.exp) return session;
return null;
} catch { return null; }
}
export function getSession(req: express.Request): UserSession | null {
return verifySession(readCookie(req, 'hub_session'));
}
// ── Admin Token (backward compat with soundboard plugin) ──
function signAdminTokenCompat(adminPwd: string): string {
const payload = { iat: Date.now(), exp: Date.now() + ADMIN_MAX_AGE * 1000 };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
// ── Discord OAuth2 ──
function getRedirectUri(): string {
const publicUrl = process.env.PUBLIC_URL ?? '';
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/discord/callback`;
return `http://localhost:${process.env.PORT ?? 8080}/api/auth/discord/callback`;
}
export function isDiscordConfigured(): boolean {
return !!(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET);
}
function getDiscordAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: getRedirectUri(),
response_type: 'code',
scope: 'identify',
state,
});
return `${DISCORD_AUTH_URL}?${params.toString()}`;
}
async function exchangeDiscordCode(code: string): Promise<string> {
const res = await fetch(DISCORD_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: getRedirectUri(),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Discord token exchange failed (${res.status}): ${text}`);
}
const data = await res.json() as { access_token: string };
return data.access_token;
}
async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Discord user fetch failed (${res.status}): ${text}`);
}
return await res.json() as DiscordUser;
}
// ── Register Routes ──
export function registerAuthRoutes(app: express.Application, adminPwd: string): void {
// Available providers
app.get('/api/auth/providers', (_req, res) => {
res.json({
discord: isDiscordConfigured(),
steam: false, // Steam OpenID — planned
admin: !!adminPwd,
});
});
// Current session
app.get('/api/auth/me', (req, res) => {
const session = getSession(req);
if (!session) {
res.json({ authenticated: false });
return;
}
res.json({
authenticated: true,
provider: session.provider,
discordId: session.discordId ?? null,
username: session.username ?? null,
avatar: session.avatar ?? null,
globalName: session.globalName ?? null,
isAdmin: session.provider === 'admin',
});
});
// Discord OAuth2 — start
app.get('/api/auth/discord', (_req, res) => {
if (!isDiscordConfigured()) {
res.status(503).json({ error: 'Discord OAuth nicht konfiguriert (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET fehlen)' });
return;
}
const state = crypto.randomBytes(16).toString('hex');
console.log(`[Auth] Discord OAuth2 redirect → ${getRedirectUri()}`);
res.redirect(getDiscordAuthUrl(state));
});
// Discord OAuth2 — callback
app.get('/api/auth/discord/callback', async (req, res) => {
const code = req.query.code as string | undefined;
if (!code) {
res.status(400).send('Kein Authorization-Code erhalten.');
return;
}
try {
const accessToken = await exchangeDiscordCode(code);
const user = await fetchDiscordUser(accessToken);
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
: null;
const session: UserSession = {
provider: 'discord',
discordId: user.id,
username: user.username,
avatar: avatarUrl,
globalName: user.global_name,
iat: Date.now(),
exp: Date.now() + SESSION_MAX_AGE * 1000,
};
const token = signSession(session);
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
console.log(`[Auth] Discord login: ${user.username} (${user.id})`);
res.redirect('/');
} catch (e) {
console.error('[Auth] Discord callback error:', e);
res.status(500).send('Discord Login fehlgeschlagen. Bitte erneut versuchen.');
}
});
// Admin login (via unified modal)
app.post('/api/auth/admin', (req, res) => {
if (!adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (!password || password !== adminPwd) {
res.status(401).json({ error: 'Falsches Passwort' });
return;
}
const session: UserSession = {
provider: 'admin',
username: 'Admin',
iat: Date.now(),
exp: Date.now() + ADMIN_MAX_AGE * 1000,
};
const hubToken = signSession(session);
const adminToken = signAdminTokenCompat(adminPwd);
// Set hub_session AND legacy admin cookie (soundboard plugin reads 'admin' cookie)
res.setHeader('Set-Cookie', [
`hub_session=${encodeURIComponent(hubToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
`admin=${encodeURIComponent(adminToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
]);
console.log('[Auth] Admin login');
res.json({ ok: true });
});
// Logout (clears all session cookies)
app.post('/api/auth/logout', (_req, res) => {
res.setHeader('Set-Cookie', [
'hub_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
]);
res.json({ ok: true });
});
}

View file

@ -8,6 +8,7 @@ import { createClient } from './core/discord.js';
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
import { loadState, getFullState, getStateDiag } from './core/persistence.js';
import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js';
import { registerAuthRoutes } from './core/discord-auth.js';
import radioPlugin from './plugins/radio/index.js';
import soundboardPlugin from './plugins/soundboard/index.js';
import lolstatsPlugin from './plugins/lolstats/index.js';
@ -130,6 +131,9 @@ function onClientReady(botName: string, client: Client): void {
// ── Init ──
async function boot(): Promise<void> {
// ── Auth routes (before plugins so /api/auth/* is available) ──
registerAuthRoutes(app, ADMIN_PWD);
// ── Register plugins with their bot contexts ──
registerPlugin(soundboardPlugin, ctxJukebox);
registerPlugin(radioPlugin, ctxRadio);

View file

@ -17,6 +17,7 @@ import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { getSession } from '../../core/discord-auth.js';
// ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
@ -1244,6 +1245,101 @@ const soundboardPlugin: Plugin = {
});
});
// ── User Sound Preferences (Discord-authenticated) ──
// Get current user's entrance/exit sounds
app.get('/api/soundboard/user/sounds', (req, res) => {
const session = getSession(req);
if (!session?.discordId) {
res.status(401).json({ error: 'Nicht eingeloggt' });
return;
}
const userId = session.discordId;
const entrance = persistedState.entranceSounds?.[userId] ?? null;
const exit = persistedState.exitSounds?.[userId] ?? null;
res.json({ entrance, exit });
});
// Set entrance sound
app.post('/api/soundboard/user/entrance', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const { fileName } = req.body ?? {};
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
// Resolve file path (same logic as DM handler)
const resolve = (() => {
try {
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
}
return '';
} catch { return ''; }
})();
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
persistedState.entranceSounds = persistedState.entranceSounds ?? {};
persistedState.entranceSounds[session.discordId] = resolve;
writeState();
console.log(`[Soundboard] User ${session.username} (${session.discordId}) set entrance: ${resolve}`);
res.json({ ok: true, entrance: resolve });
});
// Set exit sound
app.post('/api/soundboard/user/exit', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const { fileName } = req.body ?? {};
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
const resolve = (() => {
try {
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
}
return '';
} catch { return ''; }
})();
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
persistedState.exitSounds = persistedState.exitSounds ?? {};
persistedState.exitSounds[session.discordId] = resolve;
writeState();
console.log(`[Soundboard] User ${session.username} (${session.discordId}) set exit: ${resolve}`);
res.json({ ok: true, exit: resolve });
});
// Remove entrance sound
app.delete('/api/soundboard/user/entrance', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
if (persistedState.entranceSounds) {
delete persistedState.entranceSounds[session.discordId];
writeState();
}
console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed entrance sound`);
res.json({ ok: true });
});
// Remove exit sound
app.delete('/api/soundboard/user/exit', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
if (persistedState.exitSounds) {
delete persistedState.exitSounds[session.discordId];
writeState();
}
console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed exit sound`);
res.json({ ok: true });
});
// List available sounds (for user settings dropdown) - no auth required
app.get('/api/soundboard/user/available-sounds', (_req, res) => {
const allSounds = listAllSounds();
res.json(allSounds.map(s => ({ name: s.name, fileName: s.fileName, folder: s.folder, relativePath: s.relativePath })));
});
// ── Health ──
app.get('/api/soundboard/health', (_req, res) => {
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length });

View file

@ -6,6 +6,8 @@ import StreamingTab from './plugins/streaming/StreamingTab';
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
import GameLibraryTab from './plugins/game-library/GameLibraryTab';
import AdminPanel from './AdminPanel';
import LoginModal from './LoginModal';
import UserSettings from './UserSettings';
interface PluginInfo {
name: string;
@ -13,6 +15,22 @@ interface PluginInfo {
description: string;
}
interface AuthUser {
authenticated: boolean;
provider?: 'discord' | 'admin';
discordId?: string;
username?: string;
avatar?: string | null;
globalName?: string | null;
isAdmin?: boolean;
}
interface AuthProviders {
discord: boolean;
steam: boolean;
admin: boolean;
}
// Plugin tab components
const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab,
@ -41,12 +59,16 @@ export default function App() {
const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({});
// Centralized admin login state
const [isAdmin, setIsAdmin] = useState(false);
const [showAdminLogin, setShowAdminLogin] = useState(false);
// ── Unified Auth State ──
const [user, setUser] = useState<AuthUser>({ authenticated: false });
const [providers, setProviders] = useState<AuthProviders>({ discord: false, steam: false, admin: false });
const [showLoginModal, setShowLoginModal] = useState(false);
const [showUserSettings, setShowUserSettings] = useState(false);
const [showAdminPanel, setShowAdminPanel] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
// Derived state
const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true);
const isDiscordUser = user.authenticated && user.provider === 'discord';
// Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron;
@ -62,46 +84,54 @@ export default function App() {
}
}, []);
// Check admin status on mount (shared cookie — any endpoint works)
// Check auth status + providers on mount
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then((data: AuthUser) => setUser(data))
.catch(() => {});
fetch('/api/auth/providers')
.then(r => r.json())
.then((data: AuthProviders) => setProviders(data))
.catch(() => {});
// Also check legacy admin cookie (backward compat)
fetch('/api/soundboard/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(!!d.authenticated))
.then(d => {
if (d.authenticated) {
setUser(prev => prev.authenticated ? prev : { authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true });
}
})
.catch(() => {});
}, []);
// Escape key closes admin login modal
useEffect(() => {
if (!showAdminLogin) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAdminLogin(false); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [showAdminLogin]);
async function handleAdminLogin() {
setAdminError('');
// Admin login handler (for LoginModal)
async function handleAdminLogin(password: string): Promise<boolean> {
try {
const resp = await fetch('/api/soundboard/admin/login', {
const resp = await fetch('/api/auth/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
body: JSON.stringify({ password }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
setShowAdminLogin(false);
} else {
setAdminError('Falsches Passwort');
setUser({ authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true });
return true;
}
return false;
} catch {
setAdminError('Verbindung fehlgeschlagen');
return false;
}
}
async function handleAdminLogout() {
await fetch('/api/soundboard/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
// Unified logout
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
setUser({ authenticated: false });
setShowUserSettings(false);
setShowAdminPanel(false);
}
// Electron auto-update listeners
@ -203,6 +233,17 @@ export default function App() {
'game-library': '\u{1F3AE}',
};
// What happens when the user button is clicked
function handleUserButtonClick() {
if (!user.authenticated) {
setShowLoginModal(true);
} else if (isAdmin) {
setShowAdminPanel(true);
} else if (isDiscordUser) {
setShowUserSettings(true);
}
}
return (
<div className="hub-app">
<header className="hub-header">
@ -238,14 +279,34 @@ export default function App() {
<span className="hub-download-label">Desktop App</span>
</a>
)}
{/* Unified Login / User button */}
<button
className={`hub-admin-btn ${isAdmin ? 'active' : ''}`}
onClick={() => isAdmin ? setShowAdminPanel(true) : setShowAdminLogin(true)}
onContextMenu={e => { if (isAdmin) { e.preventDefault(); handleAdminLogout(); } }}
title={isAdmin ? 'Admin Panel (Rechtsklick = Abmelden)' : 'Admin Login'}
className={`hub-user-btn ${user.authenticated ? 'logged-in' : ''} ${isAdmin ? 'admin' : ''}`}
onClick={handleUserButtonClick}
onContextMenu={e => {
if (user.authenticated) { e.preventDefault(); handleLogout(); }
}}
title={
user.authenticated
? `${user.globalName || user.username || 'Admin'} (Rechtsklick = Abmelden)`
: 'Anmelden'
}
>
{isAdmin ? '\uD83D\uDD13' : '\uD83D\uDD12'}
{user.authenticated ? (
isDiscordUser && user.avatar ? (
<img src={user.avatar} alt="" className="hub-user-avatar" />
) : (
<span className="hub-user-icon">{isAdmin ? '\uD83D\uDD27' : '\uD83D\uDC64'}</span>
)
) : (
<>
<span className="hub-user-icon">{'\uD83D\uDD12'}</span>
<span className="hub-user-label">Login</span>
</>
)}
</button>
<button
className="hub-refresh-btn"
onClick={() => window.location.reload()}
@ -365,36 +426,32 @@ export default function App() {
</div>
)}
{showAdminLogin && (
<div className="hub-admin-overlay" onClick={() => setShowAdminLogin(false)}>
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
<div className="hub-admin-modal-header">
<span>{'\uD83D\uDD12'} Admin Login</span>
<button className="hub-admin-modal-close" onClick={() => setShowAdminLogin(false)}>
{'\u2715'}
</button>
</div>
<div className="hub-admin-modal-body">
<input
type="password"
className="hub-admin-input"
placeholder="Admin-Passwort..."
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
autoFocus
{/* Login Modal */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onAdminLogin={handleAdminLogin}
providers={providers}
/>
{adminError && <p className="hub-admin-error">{adminError}</p>}
<button className="hub-admin-submit" onClick={handleAdminLogin}>
Login
</button>
</div>
</div>
</div>
)}
{/* User Settings (Discord users) */}
{showUserSettings && isDiscordUser && user.discordId && (
<UserSettings
user={{
discordId: user.discordId,
username: user.username ?? '',
avatar: user.avatar ?? null,
globalName: user.globalName ?? null,
}}
onClose={() => setShowUserSettings(false)}
onLogout={handleLogout}
/>
)}
{/* Admin Panel */}
{showAdminPanel && isAdmin && (
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleAdminLogout(); setShowAdminPanel(false); }} />
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleLogout(); setShowAdminPanel(false); }} />
)}
<main className="hub-content">

124
web/src/LoginModal.tsx Normal file
View file

@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
interface LoginModalProps {
onClose: () => void;
onAdminLogin: (password: string) => Promise<boolean>;
providers: { discord: boolean; steam: boolean; admin: boolean };
}
export default function LoginModal({ onClose, onAdminLogin, providers }: LoginModalProps) {
const [showAdminForm, setShowAdminForm] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
const [loading, setLoading] = useState(false);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showAdminForm) setShowAdminForm(false);
else onClose();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose, showAdminForm]);
async function handleAdminSubmit() {
if (!adminPwd.trim()) return;
setLoading(true);
setAdminError('');
const ok = await onAdminLogin(adminPwd);
setLoading(false);
if (ok) {
setAdminPwd('');
onClose();
} else {
setAdminError('Falsches Passwort');
}
}
return (
<div className="hub-login-overlay" onClick={onClose}>
<div className="hub-login-modal" onClick={e => e.stopPropagation()}>
<div className="hub-login-modal-header">
<span>{'\uD83D\uDD10'} Anmelden</span>
<button className="hub-login-modal-close" onClick={onClose}>
{'\u2715'}
</button>
</div>
{!showAdminForm ? (
<div className="hub-login-modal-body">
<p className="hub-login-subtitle">Melde dich an, um deine Einstellungen zu verwalten.</p>
<div className="hub-login-providers">
{/* Discord */}
{providers.discord && (
<a href="/api/auth/discord" className="hub-login-provider-btn discord">
<svg className="hub-login-provider-icon" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<span>Mit Discord anmelden</span>
</a>
)}
{/* Steam — placeholder */}
<button className="hub-login-provider-btn steam" disabled title="Bald verfügbar">
<svg className="hub-login-provider-icon" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 12.001-5.373 12.001-12S18.606 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303a3.015 3.015 0 0 0-3.016-3.016 3.015 3.015 0 0 0-3.016 3.016 3.015 3.015 0 0 0 3.016 3.016 3.015 3.015 0 0 0 3.016-3.016zm-5.273-.005c0-1.248 1.013-2.26 2.26-2.26 1.246 0 2.26 1.013 2.26 2.26 0 1.247-1.014 2.26-2.26 2.26-1.248 0-2.26-1.013-2.26-2.26z" />
</svg>
<span>Steam Login</span>
<span className="hub-login-soon">bald</span>
</button>
{/* Admin */}
{providers.admin && (
<button
className="hub-login-provider-btn admin"
onClick={() => setShowAdminForm(true)}
>
<span className="hub-login-provider-icon-emoji">{'\uD83D\uDD27'}</span>
<span>Admin Login</span>
</button>
)}
</div>
{!providers.discord && (
<p className="hub-login-hint">
{'\u2139\uFE0F'} Discord Login ist nicht konfiguriert. Der Server braucht DISCORD_CLIENT_ID und DISCORD_CLIENT_SECRET.
</p>
)}
</div>
) : (
<div className="hub-login-modal-body">
<button className="hub-login-back" onClick={() => { setShowAdminForm(false); setAdminError(''); }}>
{'\u2190'} Zurück
</button>
<div className="hub-login-admin-form">
<label className="hub-login-admin-label">{'\uD83D\uDD27'} Admin-Passwort</label>
<input
type="password"
className="hub-login-admin-input"
placeholder="Passwort eingeben..."
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminSubmit()}
autoFocus
disabled={loading}
/>
{adminError && <p className="hub-login-admin-error">{adminError}</p>}
<button
className="hub-login-admin-submit"
onClick={handleAdminSubmit}
disabled={loading || !adminPwd.trim()}
>
{loading ? 'Prüfe...' : 'Einloggen'}
</button>
</div>
</div>
)}
</div>
</div>
);
}

254
web/src/UserSettings.tsx Normal file
View file

@ -0,0 +1,254 @@
import { useState, useEffect, useCallback } from 'react';
interface UserInfo {
discordId: string;
username: string;
avatar: string | null;
globalName: string | null;
}
interface SoundOption {
name: string;
fileName: string;
folder: string;
relativePath: string;
}
interface UserSettingsProps {
user: UserInfo;
onClose: () => void;
onLogout: () => void;
}
export default function UserSettings({ user, onClose, onLogout }: UserSettingsProps) {
const [entranceSound, setEntranceSound] = useState<string | null>(null);
const [exitSound, setExitSound] = useState<string | null>(null);
const [availableSounds, setAvailableSounds] = useState<SoundOption[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<'entrance' | 'exit' | null>(null);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [activeSection, setActiveSection] = useState<'entrance' | 'exit'>('entrance');
// Fetch current sounds + available sounds
useEffect(() => {
Promise.all([
fetch('/api/soundboard/user/sounds', { credentials: 'include' }).then(r => r.json()),
fetch('/api/soundboard/user/available-sounds').then(r => r.json()),
])
.then(([userSounds, sounds]) => {
setEntranceSound(userSounds.entrance ?? null);
setExitSound(userSounds.exit ?? null);
setAvailableSounds(sounds);
setLoading(false);
})
.catch(() => {
setMessage({ text: 'Fehler beim Laden der Einstellungen', type: 'error' });
setLoading(false);
});
}, []);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
const showMessage = useCallback((text: string, type: 'success' | 'error') => {
setMessage({ text, type });
setTimeout(() => setMessage(null), 3000);
}, []);
async function setSound(type: 'entrance' | 'exit', fileName: string) {
setSaving(type);
try {
const resp = await fetch(`/api/soundboard/user/${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName }),
credentials: 'include',
});
if (resp.ok) {
const data = await resp.json();
if (type === 'entrance') setEntranceSound(data.entrance);
else setExitSound(data.exit);
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound gesetzt!`, 'success');
} else {
const err = await resp.json().catch(() => ({ error: 'Unbekannter Fehler' }));
showMessage(err.error || 'Fehler', 'error');
}
} catch {
showMessage('Verbindungsfehler', 'error');
}
setSaving(null);
}
async function removeSound(type: 'entrance' | 'exit') {
setSaving(type);
try {
const resp = await fetch(`/api/soundboard/user/${type}`, {
method: 'DELETE',
credentials: 'include',
});
if (resp.ok) {
if (type === 'entrance') setEntranceSound(null);
else setExitSound(null);
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound entfernt`, 'success');
}
} catch {
showMessage('Verbindungsfehler', 'error');
}
setSaving(null);
}
// Group sounds by folder
const folders = new Map<string, SoundOption[]>();
const q = search.toLowerCase();
for (const s of availableSounds) {
if (q && !s.name.toLowerCase().includes(q) && !s.fileName.toLowerCase().includes(q)) continue;
const key = s.folder || 'Allgemein';
if (!folders.has(key)) folders.set(key, []);
folders.get(key)!.push(s);
}
// Sort folders alphabetically, "Allgemein" first
const sortedFolders = [...folders.entries()].sort(([a], [b]) => {
if (a === 'Allgemein') return -1;
if (b === 'Allgemein') return 1;
return a.localeCompare(b);
});
const currentSound = activeSection === 'entrance' ? entranceSound : exitSound;
return (
<div className="hub-usettings-overlay" onClick={onClose}>
<div className="hub-usettings-panel" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="hub-usettings-header">
<div className="hub-usettings-user">
{user.avatar ? (
<img src={user.avatar} alt="" className="hub-usettings-avatar" />
) : (
<div className="hub-usettings-avatar-placeholder">{user.username[0]?.toUpperCase()}</div>
)}
<div className="hub-usettings-user-info">
<span className="hub-usettings-username">{user.globalName || user.username}</span>
<span className="hub-usettings-discriminator">@{user.username}</span>
</div>
</div>
<div className="hub-usettings-header-actions">
<button className="hub-usettings-logout" onClick={onLogout} title="Abmelden">
{'\uD83D\uDEAA'}
</button>
<button className="hub-usettings-close" onClick={onClose}>
{'\u2715'}
</button>
</div>
</div>
{/* Message toast */}
{message && (
<div className={`hub-usettings-toast ${message.type}`}>
{message.type === 'success' ? '\u2705' : '\u274C'} {message.text}
</div>
)}
{loading ? (
<div className="hub-usettings-loading">
<span className="hub-update-spinner" /> Lade Einstellungen...
</div>
) : (
<div className="hub-usettings-content">
{/* Section tabs */}
<div className="hub-usettings-tabs">
<button
className={`hub-usettings-tab ${activeSection === 'entrance' ? 'active' : ''}`}
onClick={() => setActiveSection('entrance')}
>
{'\uD83D\uDC4B'} Entrance-Sound
</button>
<button
className={`hub-usettings-tab ${activeSection === 'exit' ? 'active' : ''}`}
onClick={() => setActiveSection('exit')}
>
{'\uD83D\uDC4E'} Exit-Sound
</button>
</div>
{/* Current sound display */}
<div className="hub-usettings-current">
<span className="hub-usettings-current-label">
Aktuell: {' '}
</span>
{currentSound ? (
<span className="hub-usettings-current-value">
{'\uD83C\uDFB5'} {currentSound}
<button
className="hub-usettings-remove-btn"
onClick={() => removeSound(activeSection)}
disabled={saving === activeSection}
title="Entfernen"
>
{'\u2715'}
</button>
</span>
) : (
<span className="hub-usettings-current-none">Kein Sound gesetzt</span>
)}
</div>
{/* Search */}
<div className="hub-usettings-search-wrap">
<input
type="text"
className="hub-usettings-search"
placeholder="Sounds durchsuchen..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="hub-usettings-search-clear" onClick={() => setSearch('')}>
{'\u2715'}
</button>
)}
</div>
{/* Sound list */}
<div className="hub-usettings-sounds">
{sortedFolders.length === 0 ? (
<div className="hub-usettings-empty">
{search ? 'Keine Treffer' : 'Keine Sounds verfügbar'}
</div>
) : (
sortedFolders.map(([folder, sounds]) => (
<div key={folder} className="hub-usettings-folder">
<div className="hub-usettings-folder-name">{'\uD83D\uDCC1'} {folder}</div>
<div className="hub-usettings-folder-sounds">
{sounds.map(s => {
const isSelected = currentSound === s.relativePath || currentSound === s.fileName;
return (
<button
key={s.relativePath}
className={`hub-usettings-sound-btn ${isSelected ? 'selected' : ''}`}
onClick={() => setSound(activeSection, s.fileName)}
disabled={saving === activeSection}
title={s.relativePath}
>
<span className="hub-usettings-sound-icon">
{isSelected ? '\u2705' : '\uD83C\uDFB5'}
</span>
<span className="hub-usettings-sound-name">{s.name}</span>
</button>
);
})}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -2313,3 +2313,596 @@ html, body {
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:not(:disabled) {
border-color: #1b2838;
background: rgba(27, 40, 56, 0.15);
}
.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;
}
}