feat: Discord OAuth Login + User Settings GUI
- 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:
parent
a7e8407996
commit
99d69f30ba
7 changed files with 1435 additions and 60 deletions
247
server/src/core/discord-auth.ts
Normal file
247
server/src/core/discord-auth.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
175
web/src/App.tsx
175
web/src/App.tsx
|
|
@ -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
124
web/src/LoginModal.tsx
Normal 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
254
web/src/UserSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue