Compare commits

...

11 commits

Author SHA1 Message Date
Daniel
7d89ba6978 CI Redesign: Warm-Brown Palette, DM Sans, vereinfachtes Accent-System
- Neon-Forge Design-System ersetzt durch warme Braun-Palette
- Fonts: DM Sans + DM Mono statt Space Grotesk + JetBrains Mono
- Glassmorphism (backdrop-filter: blur) komplett entfernt
- Accent-Gradients und Glow-Effekte durch solide Farben ersetzt
- Border-Radius vereinheitlicht (max 8px, Standard 4px)
- Button-Hoehen: 26px Standard, 24px Small, 32px Large
- Noise-Texture-Overlay entfernt
- Plugin-CSS (Soundboard, Game-Library, LoLStats, Streaming, Watch-Together) angepasst

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:45:51 +01:00
Daniel
970e1c2bc1 CI: docker image prune nach jedem Deploy
Entfernt dangling/orphan Images automatisch nach docker pull + deploy.
Verhindert dass sich alte untagged Images ansammeln (~533MB pro Build).

[skip ci]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:37:41 +01:00
Daniel
41d2c0e570 Redesign: Sidebar-Layout + neues Design-System "Neon Forge"
- styles.css: Komplettes Design-System v2.0 mit HSL-basierten
  Accent-Themes (Ember, Amethyst, Ocean, Jade, Rose, Crimson),
  Glassmorphism-Tokens, Typography-Scale, Spacing-System
- App.tsx: Sidebar-Navigation statt Top-Tabs, Accent-Picker
  in Sidebar, Channel-Dropdown, User-Panel mit Connection-Status
- SoundboardTab.tsx: Eigener Content-Header mit Search, Playback-
  Controls (Stop/Random/Party), Alphabetische Kategorie-Header,
  Most-Played-Strip mit Rang-Chips, Connection-Badge
- soundboard.css: Alle Styles auf globale Design-Tokens umgestellt,
  neue BEM-Klassen fuer Filter-Chips, Channel-Dropdown, Most-Played

Alle bestehenden Funktionalitaeten bleiben erhalten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:54:43 +01:00
Daniel
c3ac4432ca Streaming: Qualitaets-Dropdown schmaler (250px -> 210px)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:21 +01:00
Daniel
7e09575009 Streaming: fps bei allen Qualitaetsstufen anzeigen
Labels: Niedrig · 4 Mbit · 60fps bis Max · 50 Mbit · 165fps.
Dropdown auf 250px verbreitert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:56:32 +01:00
Daniel
041557c885 UI: Avatar entfernt, Streaming-Topbar mit Labels
- DK-Avatar aus Header entfernt (kein Zweck)
- Streaming-Felder haben jetzt Ueberschriften: Name, Titel, Passwort, Qualitaet
- Passwort-Feld von 140px auf 180px verbreitert
- Topbar aligned an Feldunterkante (flex-end)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:52:42 +01:00
Daniel
3127d31355 Streaming: Presets zeigen jetzt Bitrate statt Aufloesung
Aufloesung ist immer nativ (Monitor des Broadcasters), die Presets
steuern nur Bitrate und FPS. Labels entsprechend angepasst:
Niedrig (4 Mbit) bis Max (50 Mbit/165Hz). Dropdown auf 200px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:46:20 +01:00
Daniel
10fcde125d Streaming: 30fps Presets entfernt, Dropdown breiter
Qualitaetsstufen: 720p60, 1080p60, 2K60, 4K60, 4K165 Ultra.
Dropdown von 120px auf 160px verbreitert damit Text nicht abgeschnitten wird.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:42:48 +01:00
Daniel
f27093b87a Refactor: Admin-Login aus allen Plugins entfernt
Duplizierte Auth-Logik aus Notifications, Game Library und Streaming
Plugins komplett entfernt (-251 Zeilen). Alle Plugins nutzen jetzt
die zentrale Auth aus core/auth.ts via isAdmin Prop.

Admin-Buttons (Settings-Zahnrad) erscheinen nur noch wenn global
eingeloggt. Kein separater Login pro Tab mehr noetig.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:22:07 +01:00
Daniel
b3080fb763 Refactor: Zentralisiertes Admin-Login für alle Tabs
Admin-Auth aus Soundboard-Plugin in core/auth.ts extrahiert.
Ein Login-Button im Header gilt jetzt für die gesamte Webseite.
Cookie-basiert (HMAC-SHA256, 7 Tage) — überlebt Page-Reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:11:34 +01:00
Daniel
8abe0775a5 feat: Discord-style glass morphism UI redesign + nightly CI/CD
- App shell: gradient title, glass admin modal, avatar, admin login/logout
- All plugin empty states: floating icon animations, updated typography
- Soundboard: orange accent theme replacing blurple default
- Global styles: glass morphism variables, Discord-dark color palette
- CI/CD: nightly deploy (stops main, starts nightly on port 8085) + manual restore-main job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:12:02 +01:00
21 changed files with 4080 additions and 2606 deletions

View file

@ -170,6 +170,98 @@ deploy:
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \ -v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
"$DEPLOY_IMAGE" "$DEPLOY_IMAGE"
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}" - docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
- echo "[Deploy] Cleaning up dangling images..."
- docker image prune -f || true
deploy-nightly:
stage: deploy
image: docker:latest
needs: [docker-build]
rules:
- if: $CI_COMMIT_BRANCH == "nightly"
variables:
DEPLOY_IMAGE: "$INTERNAL_REGISTRY/root/gaming-hub:nightly"
CONTAINER_NAME: "gaming-hub-nightly"
script:
- echo "[Nightly Deploy] Logging into registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login "$INTERNAL_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- echo "[Nightly Deploy] Pulling $DEPLOY_IMAGE..."
- docker pull "$DEPLOY_IMAGE"
- echo "[Nightly Deploy] Stopping main container..."
- docker stop gaming-hub || true
- docker rm gaming-hub || true
- echo "[Nightly Deploy] Stopping old nightly container..."
- docker stop "$CONTAINER_NAME" || true
- docker rm "$CONTAINER_NAME" || true
- echo "[Nightly Deploy] Starting $CONTAINER_NAME..."
- |
docker run -d \
--name "$CONTAINER_NAME" \
--network pangolin \
--restart unless-stopped \
--label "channel=nightly" \
-p 8085:8080 \
-e TZ=Europe/Berlin \
-e NODE_ENV=production \
-e PORT=8080 \
-e DATA_DIR=/data \
-e SOUNDS_DIR=/data/sounds \
-e "NODE_OPTIONS=--dns-result-order=ipv4first" \
-e ADMIN_PWD="$GAMING_HUB_ADMIN_PWD" \
-e PCM_CACHE_MAX_MB=2048 \
-e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \
-e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \
-e DISCORD_TOKEN_NOTIFICATIONS="$GAMING_HUB_DISCORD_NOTIFICATIONS" \
-e PUBLIC_URL="$GAMING_HUB_PUBLIC_URL" \
-e STEAM_API_KEY="$STEAM_API_KEY" \
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
"$DEPLOY_IMAGE"
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
restore-main:
stage: deploy
image: docker:latest
needs: [docker-build]
rules:
- if: $CI_COMMIT_BRANCH == "nightly"
when: manual
allow_failure: true
variables:
DEPLOY_IMAGE: "$INTERNAL_REGISTRY/root/gaming-hub:latest"
CONTAINER_NAME: "gaming-hub"
script:
- echo "[Restore Main] Logging into registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login "$INTERNAL_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- echo "[Restore Main] Stopping nightly container..."
- docker stop gaming-hub-nightly || true
- docker rm gaming-hub-nightly || true
- echo "[Restore Main] Pulling $DEPLOY_IMAGE..."
- docker pull "$DEPLOY_IMAGE"
- echo "[Restore Main] Starting $CONTAINER_NAME..."
- |
docker run -d \
--name "$CONTAINER_NAME" \
--network pangolin \
--restart unless-stopped \
-p 8085:8080 \
-e TZ=Europe/Berlin \
-e NODE_ENV=production \
-e PORT=8080 \
-e DATA_DIR=/data \
-e SOUNDS_DIR=/data/sounds \
-e "NODE_OPTIONS=--dns-result-order=ipv4first" \
-e ADMIN_PWD="$GAMING_HUB_ADMIN_PWD" \
-e PCM_CACHE_MAX_MB=2048 \
-e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \
-e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \
-e DISCORD_TOKEN_NOTIFICATIONS="$GAMING_HUB_DISCORD_NOTIFICATIONS" \
-e PUBLIC_URL="$GAMING_HUB_PUBLIC_URL" \
-e STEAM_API_KEY="$STEAM_API_KEY" \
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
"$DEPLOY_IMAGE"
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
bump-version: bump-version:
stage: bump-version stage: bump-version

61
server/src/core/auth.ts Normal file
View file

@ -0,0 +1,61 @@
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
const COOKIE_NAME = 'admin_token';
const TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // 7 days
type AdminPayload = { iat: number; exp: number };
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
export function signAdminToken(adminPwd: string): string {
const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + TOKEN_TTL_MS };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
export function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!token || !adminPwd) return false;
const [body, sig] = token.split('.');
if (!body || !sig) return false;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
export function readCookie(req: 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;
}
export function setAdminCookie(res: Response, token: string): void {
res.setHeader('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
}
export function clearAdminCookie(res: Response): void {
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`);
}
export function requireAdmin(adminPwd: string) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!adminPwd) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; }
if (!verifyAdminToken(adminPwd, readCookie(req, COOKIE_NAME))) {
res.status(401).json({ error: 'Nicht eingeloggt' });
return;
}
next();
};
}
export { COOKIE_NAME };

View file

@ -1,24 +1,8 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import type { PluginContext } from './plugin.js'; import type { PluginContext } from './plugin.js';
/** // Re-export centralised admin auth
* Admin authentication middleware. export { requireAdmin } from './auth.js';
* Checks `x-admin-password` header against ADMIN_PWD env var.
*/
export function adminAuth(ctx: PluginContext) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!ctx.adminPwd) {
res.status(503).json({ error: 'ADMIN_PWD not configured' });
return;
}
const pwd = req.headers['x-admin-password'] as string | undefined;
if (pwd !== ctx.adminPwd) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
next();
};
}
/** /**
* Guild filter middleware. * Guild filter middleware.

View file

@ -8,6 +8,7 @@ import { createClient } from './core/discord.js';
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
import { loadState, getFullState, getStateDiag } from './core/persistence.js'; import { loadState, getFullState, getStateDiag } from './core/persistence.js';
import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js';
import { signAdminToken, verifyAdminToken, readCookie, setAdminCookie, clearAdminCookie, COOKIE_NAME } from './core/auth.js';
import radioPlugin from './plugins/radio/index.js'; import radioPlugin from './plugins/radio/index.js';
import soundboardPlugin from './plugins/soundboard/index.js'; import soundboardPlugin from './plugins/soundboard/index.js';
import lolstatsPlugin from './plugins/lolstats/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js';
@ -93,16 +94,25 @@ app.get('/api/health', (_req, res) => {
}); });
}); });
// ── Admin Login ── // ── Admin Auth (centralised) ──
app.post('/api/admin/login', (req, res) => { app.post('/api/admin/login', (req, res) => {
if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; }
const { password } = req.body ?? {}; const { password } = req.body ?? {};
if (password === ADMIN_PWD) { if (password === ADMIN_PWD) {
const token = signAdminToken(ADMIN_PWD);
setAdminCookie(res, token);
res.json({ ok: true }); res.json({ ok: true });
} else { } else {
res.status(401).json({ error: 'Invalid password' }); res.status(401).json({ error: 'Invalid password' });
} }
}); });
app.post('/api/admin/logout', (_req, res) => {
clearAdminCookie(res);
res.json({ ok: true });
});
app.get('/api/admin/status', (req, res) => {
res.json({ authenticated: verifyAdminToken(ADMIN_PWD, readCookie(req, COOKIE_NAME)) });
});
// ── API: List plugins ── // ── API: List plugins ──
app.get('/api/plugins', (_req, res) => { app.get('/api/plugins', (_req, res) => {

View file

@ -1,6 +1,7 @@
import type express from 'express'; import type express from 'express';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
@ -58,34 +59,6 @@ const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166
// ── Admin auth helpers (same system as soundboard) ── // ── Admin auth helpers (same system as soundboard) ──
function readCookie(req: express.Request, name: string): string | undefined {
const raw = req.headers.cookie || '';
const match = raw.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : undefined;
}
function b64url(str: string): string {
return Buffer.from(str).toString('base64url');
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!token || !adminPwd) return false;
const [body, sig] = token.split('.');
if (!body || !sig) return false;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as { iat: number; exp: number };
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
function signAdminToken(adminPwd: string): string {
const payload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
// ── Data Persistence ── // ── Data Persistence ──
@ -893,37 +866,7 @@ const gameLibraryPlugin: Plugin = {
// Admin endpoints (same auth as soundboard) // Admin endpoints (same auth as soundboard)
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { const requireAdmin = requireAdminFactory(ctx.adminPwd);
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) {
res.status(401).json({ error: 'Nicht eingeloggt' });
return;
}
next();
};
// ── GET /api/game-library/admin/status ──
app.get('/api/game-library/admin/status', (req, res) => {
if (!ctx.adminPwd) { res.json({ admin: false, configured: false }); return; }
const valid = verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'));
res.json({ admin: valid, configured: true });
});
// ── POST /api/game-library/admin/login ──
app.post('/api/game-library/admin/login', (req, res) => {
const password = String(req.body?.password || '');
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd);
res.setHeader('Set-Cookie', `admin=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
res.json({ ok: true });
});
// ── POST /api/game-library/admin/logout ──
app.post('/api/game-library/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
res.json({ ok: true });
});
// ── GET /api/game-library/admin/profiles ── Alle Profile mit Details // ── GET /api/game-library/admin/profiles ── Alle Profile mit Details
app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => { app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => {

View file

@ -1,8 +1,8 @@
import type express from 'express'; import type express from 'express';
import crypto from 'node:crypto';
import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js'; import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { getState, setState } from '../../core/persistence.js'; import { getState, setState } from '../../core/persistence.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
const NB = '[Notifications]'; const NB = '[Notifications]';
@ -26,40 +26,6 @@ let _client: Client | null = null;
let _ctx: PluginContext | null = null; let _ctx: PluginContext | null = null;
let _publicUrl = ''; let _publicUrl = '';
// ── Admin Auth (JWT-like with HMAC) ──
type AdminPayload = { iat: number; exp: number };
function readCookie(req: express.Request, name: string): string | undefined {
const header = req.headers.cookie;
if (!header) return undefined;
const match = header.split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`));
return match?.split('=').slice(1).join('=');
}
function b64url(str: string): string {
return Buffer.from(str).toString('base64url');
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!adminPwd || !token) return false;
const parts = token.split('.');
if (parts.length !== 2) return false;
const [body, sig] = parts;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as AdminPayload;
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
function signAdminToken(adminPwd: string): string {
const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
// ── Exported notification functions (called by other plugins) ── // ── Exported notification functions (called by other plugins) ──
@ -159,33 +125,7 @@ const notificationsPlugin: Plugin = {
}, },
registerRoutes(app, ctx) { registerRoutes(app, ctx) {
const requireAdmin = (req: express.Request, res: express.Response, next: () => void): void => { const requireAdmin = requireAdminFactory(ctx.adminPwd);
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
next();
};
// Admin status
app.get('/api/notifications/admin/status', (req, res) => {
if (!ctx.adminPwd) { res.json({ admin: false }); return; }
res.json({ admin: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
});
// Admin login
app.post('/api/notifications/admin/login', (req, res) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd);
res.setHeader('Set-Cookie', `admin=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 86400}`);
res.json({ ok: true });
});
// Admin logout
app.post('/api/notifications/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
res.json({ ok: true });
});
// List available text channels (requires admin) // List available text channels (requires admin)
app.get('/api/notifications/channels', requireAdmin, async (_req, res) => { app.get('/api/notifications/channels', requireAdmin, async (_req, res) => {

View file

@ -17,6 +17,7 @@ import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js'; import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
// ── Config (env) ── // ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
@ -583,33 +584,6 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
if (relativeKey) incrementPlaysFor(relativeKey); if (relativeKey) incrementPlaysFor(relativeKey);
} }
// ── Admin Auth (JWT-like with HMAC) ──
type AdminPayload = { iat: number; exp: number };
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function signAdminToken(adminPwd: string, payload: AdminPayload): string {
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!token || !adminPwd) return false;
const [body, sig] = token.split('.');
if (!body || !sig) return false;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
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;
}
// ── Party Mode ── // ── Party Mode ──
function schedulePartyPlayback(guildId: string, channelId: string) { function schedulePartyPlayback(guildId: string, channelId: string) {
@ -775,28 +749,7 @@ const soundboardPlugin: Plugin = {
}, },
registerRoutes(app: express.Application, ctx: PluginContext) { registerRoutes(app: express.Application, ctx: PluginContext) {
const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { const requireAdmin = requireAdminFactory(ctx.adminPwd);
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
next();
};
// ── Admin Auth ──
app.post('/api/soundboard/admin/login', (req, res) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 });
res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
res.json({ ok: true });
});
app.post('/api/soundboard/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
res.json({ ok: true });
});
app.get('/api/soundboard/admin/status', (req, res) => {
res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
});
// ── Sounds ── // ── Sounds ──
app.get('/api/soundboard/sounds', (req, res) => { app.get('/api/soundboard/sounds', (req, res) => {

1
web/dist/assets/index-BStrUazC.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title> <title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script> <script type="module" crossorigin src="/assets/index-CG_5yn3u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css"> <link rel="stylesheet" crossorigin href="/assets/index-BStrUazC.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -13,7 +13,7 @@ interface PluginInfo {
} }
// Plugin tab components // Plugin tab components
const tabComponents: Record<string, React.FC<{ data: any }>> = { const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab, radio: RadioTab,
soundboard: SoundboardTab, soundboard: SoundboardTab,
lolstats: LolstatsTab, lolstats: LolstatsTab,
@ -22,7 +22,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
'game-library': GameLibraryTab, 'game-library': GameLibraryTab,
}; };
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) {
tabComponents[pluginName] = component; tabComponents[pluginName] = component;
} }
@ -40,6 +40,21 @@ export default function App() {
const [showVersionModal, setShowVersionModal] = useState(false); const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({}); const [pluginData, setPluginData] = useState<Record<string, any>>({});
// Admin state
const [adminLoggedIn, setAdminLoggedIn] = useState(false);
const [showAdminModal, setShowAdminModal] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
const [adminError, setAdminError] = useState('');
// Accent theme state
const [accentTheme, setAccentTheme] = useState<string>(() => {
return localStorage.getItem('gaming-hub-accent') || 'ember';
});
useEffect(() => {
localStorage.setItem('gaming-hub-accent', accentTheme);
}, [accentTheme]);
// Electron auto-update state // Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron; const isElectron = !!(window as any).electronAPI?.isElectron;
const electronVersion = isElectron ? (window as any).electronAPI.version : null; const electronVersion = isElectron ? (window as any).electronAPI.version : null;
@ -130,13 +145,60 @@ export default function App() {
const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev'; const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev';
// Close version modal on Escape // Close modals on Escape
useEffect(() => { useEffect(() => {
if (!showVersionModal) return; if (!showVersionModal && !showAdminModal) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); }; const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowVersionModal(false);
setShowAdminModal(false);
}
};
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [showVersionModal]); }, [showVersionModal, showAdminModal]);
// Check admin status on mount (cookie-based, survives reload)
useEffect(() => {
fetch('/api/admin/status', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.authenticated) setAdminLoggedIn(true); })
.catch(() => {});
}, []);
// Admin login handler
const handleAdminLogin = () => {
if (!adminPassword) return;
fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ password: adminPassword }),
})
.then(r => {
if (r.ok) {
setAdminLoggedIn(true);
setAdminPassword('');
setAdminError('');
setShowAdminModal(false);
} else {
setAdminError('Falsches Passwort');
}
})
.catch(() => setAdminError('Verbindungsfehler'));
};
const handleAdminLogout = () => {
fetch('/api/admin/logout', { method: 'POST', credentials: 'include' })
.then(() => {
setAdminLoggedIn(false);
setShowAdminModal(false);
})
.catch(() => {
setAdminLoggedIn(false);
setShowAdminModal(false);
});
};
// Tab icon mapping // Tab icon mapping
@ -153,52 +215,103 @@ export default function App() {
'game-library': '\u{1F3AE}', 'game-library': '\u{1F3AE}',
}; };
// Accent swatches configuration
const accentSwatches: { name: string; color: string }[] = [
{ name: 'ember', color: '#e67e22' },
{ name: 'amethyst', color: '#8e44ad' },
{ name: 'ocean', color: '#2e86c1' },
{ name: 'jade', color: '#27ae60' },
{ name: 'rose', color: '#e74c8b' },
{ name: 'crimson', color: '#d63031' },
];
// Find active plugin for display
const activePlugin = plugins.find(p => p.name === activeTab);
return ( return (
<div className="hub-app"> <div className="app-shell" data-accent={accentTheme}>
<header className="hub-header"> {/* ===== SIDEBAR ===== */}
<div className="hub-header-left"> <aside className="app-sidebar">
<span className="hub-logo">{'\u{1F3AE}'}</span> {/* Sidebar Header: Logo + Brand */}
<span className="hub-title">Gaming Hub</span> <div className="sidebar-header">
<span className={`hub-conn-dot ${connected ? 'online' : ''}`} /> <div className="app-logo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<rect x="6" y="3" width="12" height="18" rx="2" />
<path d="M9 18h6M12 7v4" />
</svg>
</div>
<span className="app-brand">Gaming Hub</span>
</div> </div>
<nav className="hub-tabs"> {/* Channel Dropdown (static placeholder) */}
<div className="sidebar-channel">
<div className="channel-dropdown-trigger">
<svg className="channel-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" />
</svg>
<span className="channel-name">Sprechstunde</span>
<svg className="channel-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</div>
{/* Section Label */}
<div className="sidebar-section-label">Plugins</div>
{/* Navigation */}
<nav className="sidebar-nav">
{plugins.filter(p => p.name in tabComponents).map(p => ( {plugins.filter(p => p.name in tabComponents).map(p => (
<button <button
key={p.name} key={p.name}
className={`hub-tab ${activeTab === p.name ? 'active' : ''}`} className={`nav-item ${activeTab === p.name ? 'active' : ''}`}
onClick={() => setActiveTab(p.name)} onClick={() => setActiveTab(p.name)}
title={p.description} title={p.description}
> >
<span className="hub-tab-icon">{tabIcons[p.name] ?? '\u{1F4E6}'}</span> <span className="nav-icon">{tabIcons[p.name] || '\u{1F4E6}'}</span>
<span className="hub-tab-label">{p.name}</span> <span className="nav-label">{p.name}</span>
</button> </button>
))} ))}
</nav> </nav>
<div className="hub-header-right"> {/* Accent Theme Picker */}
{!(window as any).electronAPI && ( <div className="sidebar-accent-picker">
<a {accentSwatches.map(swatch => (
className="hub-download-btn" <button
href="/downloads/GamingHub-Setup.exe" key={swatch.name}
download className={`accent-swatch ${accentTheme === swatch.name ? 'active' : ''}`}
title="Desktop App herunterladen" style={{ backgroundColor: swatch.color }}
> onClick={() => setAccentTheme(swatch.name)}
<span className="hub-download-icon">{'\u2B07\uFE0F'}</span> title={swatch.name.charAt(0).toUpperCase() + swatch.name.slice(1)}
<span className="hub-download-label">Desktop App</span> />
</a> ))}
)} </div>
{/* Sidebar Footer: User + Connection + Settings + Admin */}
<div className="sidebar-footer">
<div className="sidebar-avatar">
D
{connected && <span className={`status-dot ${connected ? 'online' : 'offline'}`} />}
</div>
<div className="sidebar-user-info">
<span className="sidebar-username">User</span>
<span className="sidebar-user-tag">
{connected ? 'Verbunden' : 'Getrennt'}
</span>
</div>
<button <button
className="hub-refresh-btn" className={`sidebar-settings ${adminLoggedIn ? 'admin-active' : ''}`}
onClick={() => window.location.reload()} onClick={() => setShowAdminModal(true)}
title="Seite neu laden" title="Admin Login"
> >
{'\u{1F504}'} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
</button> </button>
<span <button
className="hub-version hub-version-clickable" className="sidebar-settings"
onClick={() => { onClick={() => {
// Status vom Main-Prozess synchronisieren bevor Modal öffnet
if (isElectron) { if (isElectron) {
const api = (window as any).electronAPI; const api = (window as any).electronAPI;
const s = api.getUpdateStatus?.(); const s = api.getUpdateStatus?.();
@ -208,14 +321,50 @@ export default function App() {
} }
setShowVersionModal(true); setShowVersionModal(true);
}} }}
title="Versionsinformationen" title="Einstellungen & Version"
> >
v{version} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
</span> <circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</button>
</div> </div>
</header> </aside>
{/* ===== MAIN CONTENT ===== */}
<main className="app-main">
<div className="content-area">
{plugins.length === 0 ? (
<div className="hub-empty">
<span className="hub-empty-icon">{'\u{1F4E6}'}</span>
<h2>Keine Plugins geladen</h2>
<p>Plugins werden im Server konfiguriert.</p>
</div>
) : (
/* Render ALL tabs, hide inactive ones to preserve state.
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} isAdmin={adminLoggedIn} />
</div>
);
})
)}
</div>
</main>
{/* ===== VERSION MODAL ===== */}
{showVersionModal && ( {showVersionModal && (
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}> <div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
<div className="hub-version-modal" onClick={e => e.stopPropagation()}> <div className="hub-version-modal" onClick={e => e.stopPropagation()}>
@ -261,13 +410,13 @@ export default function App() {
{updateStatus === 'checking' && ( {updateStatus === 'checking' && (
<div className="hub-version-modal-update-status"> <div className="hub-version-modal-update-status">
<span className="hub-update-spinner" /> <span className="hub-update-spinner" />
Suche nach Updates Suche nach Updates...
</div> </div>
)} )}
{updateStatus === 'downloading' && ( {updateStatus === 'downloading' && (
<div className="hub-version-modal-update-status"> <div className="hub-version-modal-update-status">
<span className="hub-update-spinner" /> <span className="hub-update-spinner" />
Update wird heruntergeladen Update wird heruntergeladen...
</div> </div>
)} )}
{updateStatus === 'ready' && ( {updateStatus === 'ready' && (
@ -307,35 +456,46 @@ export default function App() {
</div> </div>
)} )}
<main className="hub-content"> {/* ===== ADMIN MODAL ===== */}
{plugins.length === 0 ? ( {showAdminModal && (
<div className="hub-empty"> <div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}>
<span className="hub-empty-icon">{'\u{1F4E6}'}</span> <div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
<h2>Keine Plugins geladen</h2> {adminLoggedIn ? (
<p>Plugins werden im Server konfiguriert.</p> <>
<div className="hub-admin-modal-title">Admin Panel</div>
<div className="hub-admin-modal-info">
<div className="hub-admin-modal-avatar">A</div>
<div className="hub-admin-modal-text">
<span className="hub-admin-modal-name">Administrator</span>
<span className="hub-admin-modal-role">Eingeloggt</span>
</div>
</div>
<button className="hub-admin-modal-logout" onClick={handleAdminLogout}>
Ausloggen
</button>
</>
) : (
<>
<div className="hub-admin-modal-title">{'\u{1F511}'} Admin Login</div>
<div className="hub-admin-modal-subtitle">Passwort eingeben um Einstellungen freizuschalten</div>
{adminError && <div className="hub-admin-modal-error">{adminError}</div>}
<input
className="hub-admin-modal-input"
type="password"
placeholder="Passwort"
value={adminPassword}
onChange={e => setAdminPassword(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAdminLogin(); }}
autoFocus
/>
<button className="hub-admin-modal-login" onClick={handleAdminLogin}>
Login
</button>
</>
)}
</div> </div>
) : ( </div>
/* Render ALL tabs, hide inactive ones to preserve state. )}
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} />
</div>
);
})
)}
</main>
</div> </div>
); );
} }

View file

@ -89,7 +89,7 @@ function formatDate(iso: string): string {
COMPONENT COMPONENT
*/ */
export default function GameLibraryTab({ data }: { data: any }) { export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) {
// ── State ── // ── State ──
const [profiles, setProfiles] = useState<ProfileSummary[]>([]); const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
@ -111,11 +111,9 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── Admin state ── // ── Admin state ──
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const isAdmin = isAdminProp;
const [adminPwd, setAdminPwd] = useState('');
const [adminProfiles, setAdminProfiles] = useState<any[]>([]); const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
const [adminLoading, setAdminLoading] = useState(false); const [adminLoading, setAdminLoading] = useState(false);
const [adminError, setAdminError] = useState('');
// ── SSE data sync ── // ── SSE data sync ──
useEffect(() => { useEffect(() => {
@ -133,42 +131,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
} catch { /* silent */ } } catch { /* silent */ }
}, []); }, []);
// ── Admin: check login status on mount ──
useEffect(() => {
fetch('/api/game-library/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
}, []);
// ── Admin: login ──
const adminLogin = useCallback(async () => {
setAdminError('');
try {
const resp = await fetch('/api/game-library/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
} else {
const d = await resp.json();
setAdminError(d.error || 'Fehler');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}, [adminPwd]);
// ── Admin: logout ──
const adminLogout = useCallback(async () => {
await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
setShowAdmin(false);
}, []);
// ── Admin: load profiles ── // ── Admin: load profiles ──
const loadAdminProfiles = useCallback(async () => { const loadAdminProfiles = useCallback(async () => {
@ -552,9 +514,11 @@ export default function GameLibraryTab({ data }: { data: any }) {
</button> </button>
)} )}
<div className="gl-login-bar-spacer" /> <div className="gl-login-bar-spacer" />
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel"> {isAdmin && (
&#x2699;&#xFE0F; <button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
</button> &#x2699;&#xFE0F;
</button>
)}
</div> </div>
{/* ── Profile Chips ── */} {/* ── Profile Chips ── */}
@ -990,29 +954,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button> <button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button>
</div> </div>
{!isAdmin ? (
<div className="gl-admin-login">
<p>Admin-Passwort eingeben:</p>
<div className="gl-admin-login-row">
<input
type="password"
className="gl-dialog-input"
placeholder="Passwort"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
autoFocus
/>
<button className="gl-admin-login-btn" onClick={adminLogin}>Login</button>
</div>
{adminError && <p className="gl-dialog-status error">{adminError}</p>}
</div>
) : (
<div className="gl-admin-content"> <div className="gl-admin-content">
<div className="gl-admin-toolbar"> <div className="gl-admin-toolbar">
<span className="gl-admin-status-text">&#x2705; Eingeloggt als Admin</span> <span className="gl-admin-status-text">&#x2705; Eingeloggt als Admin</span>
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>&#x21bb; Aktualisieren</button> <button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>&#x21bb; Aktualisieren</button>
<button className="gl-admin-logout-btn" onClick={adminLogout}>Logout</button>
</div> </div>
{adminLoading ? ( {adminLoading ? (
@ -1044,7 +989,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -62,7 +62,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 12px; padding: 6px 12px;
border-radius: 20px; border-radius: 4px;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
cursor: pointer; cursor: pointer;
@ -234,7 +234,7 @@
gap: 6px; gap: 6px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
padding: 6px 12px 6px 6px; padding: 6px 12px 6px 6px;
border-radius: 20px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all var(--transition);
} }
@ -472,24 +472,29 @@
/* ── Empty state ── */ /* ── Empty state ── */
.gl-empty { .gl-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
padding: 40px; height: 100%;
} }
.gl-empty-icon { .gl-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 16px; animation: gl-empty-float 3s ease-in-out infinite;
}
@keyframes gl-empty-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.gl-empty h3 { .gl-empty h3 {
color: var(--text-normal); font-size: 26px; font-weight: 700; color: #f2f3f5;
margin: 0 0 8px; letter-spacing: -0.5px; margin: 0;
} }
.gl-empty p { .gl-empty p {
color: var(--text-faint); font-size: 15px; color: #80848e;
margin: 0; text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
font-size: 14px;
} }
/* ── Common game playtime chips ── */ /* ── Common game playtime chips ── */
@ -698,7 +703,7 @@
} }
.gl-sort-select option { .gl-sort-select option {
background: #1a1a2e; background: #1a1810;
color: #c7d5e0; color: #c7d5e0;
} }
@ -717,7 +722,7 @@
color: #8899a6; color: #8899a6;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
padding: 5px 12px; padding: 5px 12px;
border-radius: 20px; border-radius: 4px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@ -772,12 +777,11 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(4px);
} }
.gl-dialog { .gl-dialog {
background: #2a2a3e; background: #2a2620;
border-radius: 12px; border-radius: 6px;
padding: 24px; padding: 24px;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
@ -800,9 +804,9 @@
.gl-dialog-input { .gl-dialog-input {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
background: #1a1a2e; background: #1a1810;
border: 1px solid #444; border: 1px solid #444;
border-radius: 8px; border-radius: 6px;
color: #fff; color: #fff;
font-size: 0.9rem; font-size: 0.9rem;
outline: none; outline: none;
@ -841,16 +845,16 @@
.gl-dialog-cancel { .gl-dialog-cancel {
padding: 8px 18px; padding: 8px 18px;
background: #3a3a4e; background: #322d26;
color: #ccc; color: #ccc;
border: none; border: none;
border-radius: 8px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
} }
.gl-dialog-cancel:hover { .gl-dialog-cancel:hover {
background: #4a4a5e; background: #3a352d;
} }
.gl-dialog-submit { .gl-dialog-submit {
@ -858,7 +862,7 @@
background: #a855f7; background: #a855f7;
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
@ -896,8 +900,8 @@
} }
.gl-admin-panel { .gl-admin-panel {
background: #2a2a3e; background: #2a2620;
border-radius: 12px; border-radius: 6px;
padding: 0; padding: 0;
max-width: 600px; max-width: 600px;
width: 92%; width: 92%;
@ -956,7 +960,7 @@
color: #fff; color: #fff;
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
border-radius: 8px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;

View file

@ -20,7 +20,7 @@
min-width: 0; min-width: 0;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 8px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-normal); color: var(--text-normal);
font-size: 15px; font-size: 15px;
@ -33,7 +33,7 @@
.lol-search-region { .lol-search-region {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 8px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-normal); color: var(--text-normal);
font-size: 14px; font-size: 14px;
@ -44,7 +44,7 @@
.lol-search-btn { .lol-search-btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 8px; border-radius: 4px;
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
@ -70,7 +70,7 @@
gap: 6px; gap: 6px;
padding: 4px 10px; padding: 4px 10px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 16px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;
@ -94,13 +94,13 @@
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 6px;
background: var(--bg-secondary); background: var(--bg-secondary);
margin-bottom: 12px; margin-bottom: 12px;
} }
.lol-profile-icon { .lol-profile-icon {
width: 72px; height: 72px; width: 72px; height: 72px;
border-radius: 12px; border-radius: 6px;
border: 2px solid var(--bg-tertiary); border: 2px solid var(--bg-tertiary);
object-fit: cover; object-fit: cover;
} }
@ -139,7 +139,7 @@
gap: 6px; gap: 6px;
padding: 8px 16px; padding: 8px 16px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 8px; border-radius: 4px;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
@ -170,7 +170,7 @@
.lol-ranked-card { .lol-ranked-card {
flex: 1; flex: 1;
padding: 12px 14px; padding: 12px 14px;
border-radius: 10px; border-radius: 6px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-left: 4px solid var(--bg-tertiary); border-left: 4px solid var(--bg-tertiary);
} }
@ -232,7 +232,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
min-width: 180px; min-width: 180px;
flex-shrink: 0; flex-shrink: 0;
@ -268,7 +268,7 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-left: 4px solid var(--bg-tertiary); border-left: 4px solid var(--bg-tertiary);
cursor: pointer; cursor: pointer;
@ -374,7 +374,7 @@
/* ── Match Detail (expanded) ── */ /* ── Match Detail (expanded) ── */
.lol-match-detail { .lol-match-detail {
background: var(--bg-primary); background: var(--bg-primary);
border-radius: 8px; border-radius: 4px;
padding: 8px; padding: 8px;
margin-top: 4px; margin-top: 4px;
margin-bottom: 4px; margin-bottom: 4px;
@ -447,7 +447,7 @@
.lol-error { .lol-error {
padding: 16px; padding: 16px;
border-radius: 8px; border-radius: 4px;
background: rgba(231,76,60,0.1); background: rgba(231,76,60,0.1);
color: #e74c3c; color: #e74c3c;
font-size: 13px; font-size: 13px;
@ -456,22 +456,25 @@
} }
.lol-empty { .lol-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
color: var(--text-faint); padding: 40px; height: 100%;
} }
.lol-empty-icon { .lol-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 12px; animation: lol-float 3s ease-in-out infinite;
}
@keyframes lol-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.lol-empty h3 { .lol-empty h3 {
margin: 0 0 8px; font-size: 26px; font-weight: 700; color: var(--text-normal);
color: var(--text-muted); letter-spacing: -0.5px; margin: 0;
font-size: 16px;
} }
.lol-empty p { .lol-empty p {
margin: 0; font-size: 15px; color: var(--text-muted);
font-size: 13px; text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Load more ── */ /* ── Load more ── */
@ -481,7 +484,7 @@
padding: 10px; padding: 10px;
margin-top: 8px; margin-top: 8px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 8px; border-radius: 4px;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
@ -517,7 +520,7 @@
.lol-tier-mode-btn { .lol-tier-mode-btn {
padding: 6px 14px; padding: 6px 14px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 16px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;
@ -536,7 +539,7 @@
.lol-tier-filter { .lol-tier-filter {
padding: 6px 12px; padding: 6px 12px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 8px; border-radius: 4px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-normal); color: var(--text-normal);
font-size: 12px; font-size: 12px;

View file

@ -186,24 +186,6 @@ async function apiGetVolume(guildId: string): Promise<number> {
return typeof data?.volume === 'number' ? data.volume : 1; return typeof data?.volume === 'number' ? data.volume : 1;
} }
async function apiAdminStatus(): Promise<boolean> {
const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' });
if (!res.ok) return false;
const data = await res.json();
return !!data?.authenticated;
}
async function apiAdminLogin(password: string): Promise<boolean> {
const res = await fetch(`${API_BASE}/admin/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ password })
});
return res.ok;
}
async function apiAdminLogout(): Promise<void> {
await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' });
}
async function apiAdminDelete(paths: string[]): Promise<void> { async function apiAdminDelete(paths: string[]): Promise<void> {
const res = await fetch(`${API_BASE}/admin/sounds/delete`, { const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
@ -285,14 +267,6 @@ function apiUploadFileWithName(
CONSTANTS CONSTANTS
*/ */
const THEMES = [
{ id: 'default', color: '#5865f2', label: 'Discord' },
{ id: 'purple', color: '#9b59b6', label: 'Midnight' },
{ id: 'forest', color: '#2ecc71', label: 'Forest' },
{ id: 'sunset', color: '#e67e22', label: 'Sunset' },
{ id: 'ocean', color: '#3498db', label: 'Ocean' },
];
const CAT_PALETTE = [ const CAT_PALETTE = [
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
'#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16',
@ -324,13 +298,14 @@ interface VoiceStats {
interface SoundboardTabProps { interface SoundboardTabProps {
data: any; data: any;
isAdmin?: boolean;
} }
/* /*
COMPONENT COMPONENT
*/ */
export default function SoundboardTab({ data }: SoundboardTabProps) { export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: SoundboardTabProps) {
/* ── Data ── */ /* ── Data ── */
const [sounds, setSounds] = useState<Sound[]>([]); const [sounds, setSounds] = useState<Sound[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@ -378,9 +353,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined); const volDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
/* ── Admin ── */ /* ── Admin ── */
const [isAdmin, setIsAdmin] = useState(false); const isAdmin = isAdminProp;
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminSounds, setAdminSounds] = useState<Sound[]>([]); const [adminSounds, setAdminSounds] = useState<Sound[]>([]);
const [adminLoading, setAdminLoading] = useState(false); const [adminLoading, setAdminLoading] = useState(false);
const [adminQuery, setAdminQuery] = useState(''); const [adminQuery, setAdminQuery] = useState('');
@ -521,13 +495,12 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
} }
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
try { setIsAdmin(await apiAdminStatus()); } catch { }
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
/* ── Theme (persist only, data-theme is set on .sb-app div) ── */ /* ── Theme (persist — global theming now handled by app-shell) ── */
useEffect(() => { useEffect(() => {
localStorage.setItem('jb-theme', theme); localStorage.setItem('jb-theme', theme);
}, [theme]); }, [theme]);
@ -879,27 +852,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
} }
} }
async function handleAdminLogin() {
try {
const ok = await apiAdminLogin(adminPwd);
if (ok) {
setIsAdmin(true);
setAdminPwd('');
notify('Admin eingeloggt');
}
else notify('Falsches Passwort', 'error');
} catch { notify('Login fehlgeschlagen', 'error'); }
}
async function handleAdminLogout() {
try {
await apiAdminLogout();
setIsAdmin(false);
setAdminSelection({});
cancelRename();
notify('Ausgeloggt');
} catch { }
}
/* ── Computed ── */ /* ── Computed ── */
const displaySounds = useMemo(() => { const displaySounds = useMemo(() => {
@ -968,129 +920,119 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
RENDER RENDER
*/ */
return ( return (
<div className="sb-app" data-theme={theme} ref={sbAppRef}> <div className="sb-app" ref={sbAppRef}>
{chaosMode && <div className="party-overlay active" />} {chaosMode && <div className="party-overlay active" />}
{/* ═══ TOPBAR ═══ */} {/* ═══ CONTENT HEADER ═══ */}
<header className="topbar"> <div className="content-header">
<div className="topbar-left"> <div className="content-header__title">
<div className="sb-app-logo"> Soundboard
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span> <span className="sound-count">{totalSoundsDisplay}</span>
</div>
<span className="sb-app-title">Soundboard</span>
{/* Channel Dropdown */}
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
<button
className={`channel-btn ${channelOpen ? 'open' : ''}`}
onClick={() => setChannelOpen(!channelOpen)}
>
<span className="material-icons cb-icon">headset</span>
{selected && <span className="channel-status" />}
<span className="channel-label">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
<span className={`material-icons chevron`}>expand_more</span>
</button>
{channelOpen && (
<div className="channel-menu visible">
{Object.entries(channelsByGuild).map(([guild, chs]) => (
<React.Fragment key={guild}>
<div className="channel-menu-header">{guild}</div>
{chs.map(ch => (
<div
key={`${ch.guildId}:${ch.channelId}`}
className={`channel-option ${`${ch.guildId}:${ch.channelId}` === selected ? 'active' : ''}`}
onClick={() => handleChannelSelect(ch)}
>
<span className="material-icons co-icon">volume_up</span>
{ch.channelName}{ch.members ? ` (${ch.members})` : ''}
</div>
))}
</React.Fragment>
))}
{channels.length === 0 && (
<div className="channel-option" style={{ color: 'var(--text-faint)', cursor: 'default' }}>
Keine Channels verfuegbar
</div>
)}
</div>
)}
</div>
</div> </div>
<div className="clock-wrap"> <div className="content-header__search">
<div className="clock">{clockMain}<span className="clock-seconds">{clockSec}</span></div> <span className="material-icons" style={{ fontSize: 14 }}>search</span>
</div>
<div className="topbar-right">
{lastPlayed && (
<div className="now-playing">
<div className="np-waves active">
<div className="np-wave-bar" /><div className="np-wave-bar" />
<div className="np-wave-bar" /><div className="np-wave-bar" />
</div>
<span className="np-label">Last Played:</span> <span className="np-name">{lastPlayed}</span>
</div>
)}
{selected && (
<div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
<span className="conn-dot" />
Verbunden
{voiceStats?.voicePing != null && (
<span className="conn-ping">{voiceStats.voicePing}ms</span>
)}
</div>
)}
<button
className={`admin-btn-icon ${isAdmin ? 'active' : ''}`}
onClick={() => setShowAdmin(true)}
title="Admin"
>
<span className="material-icons">settings</span>
</button>
</div>
</header>
{/* ═══ TOOLBAR ═══ */}
<div className="toolbar">
<div className="cat-tabs">
<button
className={`cat-tab ${activeTab === 'all' ? 'active' : ''}`}
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
>
Alle
<span className="tab-count">{total}</span>
</button>
<button
className={`cat-tab ${activeTab === 'recent' ? 'active' : ''}`}
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
>
Neu hinzugefuegt
</button>
<button
className={`cat-tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
>
Favoriten
{favCount > 0 && <span className="tab-count">{favCount}</span>}
</button>
</div>
<div className="search-wrap">
<span className="material-icons search-icon">search</span>
<input <input
className="search-input"
type="text" type="text"
placeholder="Suchen..." placeholder="Suchen..."
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
/> />
{query && ( {query && (
<button className="search-clear" onClick={() => setQuery('')}> <button className="search-clear" onClick={() => setQuery('')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center' }}>
<span className="material-icons" style={{ fontSize: 14 }}>close</span> <span className="material-icons" style={{ fontSize: 14 }}>close</span>
</button> </button>
)} )}
</div> </div>
<div className="content-header__actions">
{/* Now Playing indicator */}
{lastPlayed && (
<div className="now-playing">
<div className="np-waves active">
<div className="np-wave-bar" /><div className="np-wave-bar" />
<div className="np-wave-bar" /><div className="np-wave-bar" />
</div>
<span className="np-label">Now:</span> <span className="np-name">{lastPlayed}</span>
</div>
)}
{/* Connection status */}
{selected && (
<div
className="connection-badge connected"
onClick={() => setShowConnModal(true)}
style={{ cursor: 'pointer' }}
title="Verbindungsdetails"
>
<span className="dot" />
Verbunden
{voiceStats?.voicePing != null && (
<span className="conn-ping" style={{ marginLeft: 4, fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{voiceStats.voicePing}ms</span>
)}
</div>
)}
{/* Admin button */}
{isAdmin && (
<button
className="admin-btn-icon active"
onClick={() => setShowAdmin(true)}
title="Admin"
>
<span className="material-icons">settings</span>
</button>
)}
{/* Playback controls */}
<div className="playback-controls">
<button className="playback-btn playback-btn--stop" onClick={handleStop} title="Alle stoppen">
<span className="material-icons" style={{ fontSize: 14 }}>stop</span>
Stop
</button>
<button className="playback-btn" onClick={handleRandom} title="Zufaelliger Sound">
<span className="material-icons" style={{ fontSize: 14 }}>shuffle</span>
Random
</button>
<button
className={`playback-btn playback-btn--party ${chaosMode ? 'active' : ''}`}
onClick={toggleParty}
title="Party Mode"
>
<span className="material-icons" style={{ fontSize: 14 }}>{chaosMode ? 'celebration' : 'auto_awesome'}</span>
{chaosMode ? 'Party!' : 'Party'}
</button>
</div>
</div>
</div>
{/* ═══ TOOLBAR ═══ */}
<div className="toolbar">
{/* Filter tabs */}
<button
className={`filter-chip ${activeTab === 'all' ? 'active' : ''}`}
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
>
Alle
<span className="chip-count">{total}</span>
</button>
<button
className={`filter-chip ${activeTab === 'recent' ? 'active' : ''}`}
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
>
Neu hinzugefuegt
</button>
<button
className={`filter-chip ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
>
Favoriten
{favCount > 0 && <span className="chip-count">{favCount}</span>}
</button>
<div className="toolbar__sep" />
{/* URL import */}
<div className="url-import-wrap"> <div className="url-import-wrap">
<span className="material-icons url-import-icon"> <span className="material-icons url-import-icon">
{getUrlType(importUrl) === 'youtube' ? 'smart_display' {getUrlType(importUrl) === 'youtube' ? 'smart_display'
@ -1123,113 +1065,120 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</button> </button>
</div> </div>
<div className="toolbar-spacer" /> <div className="toolbar__right">
{/* Volume */}
<div className="volume-control"> <div className="volume-control">
<span <span
className="material-icons vol-icon" className="material-icons vol-icon"
onClick={() => { onClick={() => {
const newVol = volume > 0 ? 0 : 0.5; const newVol = volume > 0 ? 0 : 0.5;
setVolume(newVol); setVolume(newVol);
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {}); if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
}} }}
> style={{ cursor: 'pointer' }}
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} >
</span> {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
<input </span>
type="range" <input
className="vol-slider" type="range"
min={0} className="volume-slider"
max={1} min={0}
step={0.01} max={1}
value={volume} step={0.01}
onChange={e => { value={volume}
const v = parseFloat(e.target.value); onChange={e => {
setVolume(v); const v = parseFloat(e.target.value);
if (guildId) { setVolume(v);
if (volDebounceRef.current) clearTimeout(volDebounceRef.current); if (guildId) {
volDebounceRef.current = setTimeout(() => { if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
apiSetVolumeLive(guildId, v).catch(() => {}); volDebounceRef.current = setTimeout(() => {
}, 120); apiSetVolumeLive(guildId, v).catch(() => {});
} }, 120);
}} }
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} }}
/> style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
<span className="vol-pct">{Math.round(volume * 100)}%</span>
</div>
<button className="tb-btn random" onClick={handleRandom} title="Zufaelliger Sound">
<span className="material-icons tb-icon">shuffle</span>
Random
</button>
<button
className={`tb-btn party ${chaosMode ? 'active' : ''}`}
onClick={toggleParty}
title="Party Mode"
>
<span className="material-icons tb-icon">{chaosMode ? 'celebration' : 'auto_awesome'}</span>
{chaosMode ? 'Party!' : 'Party'}
</button>
<button className="tb-btn stop" onClick={handleStop} title="Alle stoppen">
<span className="material-icons tb-icon">stop</span>
Stop
</button>
<div className="size-control" title="Button-Groesse">
<span className="material-icons sc-icon">grid_view</span>
<input
type="range"
className="size-slider"
min={80}
max={160}
value={cardSize}
onChange={e => setCardSize(parseInt(e.target.value))}
/>
</div>
<div className="theme-selector">
{THEMES.map(t => (
<div
key={t.id}
className={`theme-dot ${theme === t.id ? 'active' : ''}`}
style={{ background: t.color }}
title={t.label}
onClick={() => setTheme(t.id)}
/> />
))} <span className="volume-label">{Math.round(volume * 100)}%</span>
</div>
</div>
<div className="analytics-strip">
<div className="analytics-card">
<span className="material-icons analytics-icon">library_music</span>
<div className="analytics-copy">
<span className="analytics-label">Sounds gesamt</span>
<strong className="analytics-value">{totalSoundsDisplay}</strong>
</div> </div>
</div>
<div className="analytics-card analytics-wide"> {/* Channel selector */}
<span className="material-icons analytics-icon">leaderboard</span> <div className="channel-dropdown" onClick={e => e.stopPropagation()}>
<div className="analytics-copy"> <button
<span className="analytics-label">Most Played</span> className={`channel-dropdown__trigger ${channelOpen ? 'open' : ''}`}
<div className="analytics-top-list"> onClick={() => setChannelOpen(!channelOpen)}
{analyticsTop.length === 0 ? ( >
<span className="analytics-muted">Noch keine Plays</span> <span className="material-icons channel-icon" style={{ fontSize: 16 }}>headset</span>
) : ( {selected && <span className="channel-status" style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--success)', flexShrink: 0 }} />}
analyticsTop.map((item, idx) => ( <span className="channel-name">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
<span className="analytics-chip" key={item.relativePath}> <span className={`material-icons channel-arrow`} style={{ fontSize: 14 }}>expand_more</span>
{idx + 1}. {item.name} ({item.count}) </button>
</span> {channelOpen && (
)) <div className="channel-dropdown__menu" style={{ display: 'block' }}>
)} {Object.entries(channelsByGuild).map(([guild, chs]) => (
</div> <React.Fragment key={guild}>
<div className="channel-menu-header" style={{ padding: '4px 12px', fontSize: 'var(--text-xs)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-tertiary)' }}>{guild}</div>
{chs.map(ch => (
<div
key={`${ch.guildId}:${ch.channelId}`}
className={`channel-dropdown__item ${`${ch.guildId}:${ch.channelId}` === selected ? 'selected' : ''}`}
onClick={() => handleChannelSelect(ch)}
>
<span className="material-icons ch-icon" style={{ fontSize: 14 }}>volume_up</span>
{ch.channelName}{ch.members ? ` (${ch.members})` : ''}
</div>
))}
</React.Fragment>
))}
{channels.length === 0 && (
<div className="channel-dropdown__item" style={{ color: 'var(--text-tertiary)', cursor: 'default' }}>
Keine Channels verfuegbar
</div>
)}
</div>
)}
</div>
{/* Card size slider */}
<div className="size-control" title="Button-Groesse">
<span className="material-icons sc-icon" style={{ fontSize: 16 }}>grid_view</span>
<input
type="range"
className="size-slider"
min={80}
max={160}
value={cardSize}
onChange={e => setCardSize(parseInt(e.target.value))}
/>
</div> </div>
</div> </div>
</div> </div>
{/* ═══ MOST PLAYED / ANALYTICS ═══ */}
{analyticsTop.length > 0 && (
<div className="most-played">
<div className="most-played__label">
<span className="material-icons" style={{ fontSize: 12 }}>leaderboard</span>
Most Played
</div>
<div className="most-played__row">
{analyticsTop.map((item, idx) => (
<div
className="mp-chip"
key={item.relativePath}
onClick={() => {
const found = sounds.find(s => (s.relativePath ?? s.fileName) === item.relativePath);
if (found) handlePlay(found);
}}
>
<span className={`mp-chip__rank ${idx === 0 ? 'gold' : idx === 1 ? 'silver' : idx === 2 ? 'bronze' : ''}`}>{idx + 1}</span>
<span className="mp-chip__name">{item.name}</span>
<span className="mp-chip__plays">{item.count}</span>
</div>
))}
</div>
</div>
)}
{/* ═══ FOLDER CHIPS ═══ */} {/* ═══ FOLDER CHIPS ═══ */}
{activeTab === 'all' && visibleFolders.length > 0 && ( {activeTab === 'all' && visibleFolders.length > 0 && (
<div className="category-strip"> <div className="category-strip">
@ -1252,8 +1201,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</div> </div>
)} )}
{/* ═══ MAIN ═══ */} {/* ═══ SOUND GRID ═══ */}
<main className="main"> <div className="sound-grid-container">
{displaySounds.length === 0 ? ( {displaySounds.length === 0 ? (
<div className="empty-state visible"> <div className="empty-state visible">
<div className="empty-emoji">{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}</div> <div className="empty-emoji">{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}</div>
@ -1270,66 +1219,88 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
: 'Hier gibt\'s noch nichts zu hoeren.'} : 'Hier gibt\'s noch nichts zu hoeren.'}
</div> </div>
</div> </div>
) : ( ) : (() => {
<div className="sound-grid"> // Group sounds by initial letter for category headers
{displaySounds.map((s, idx) => { const groups: { letter: string; sounds: { sound: Sound; globalIdx: number }[] }[] = [];
const key = s.relativePath ?? s.fileName; let currentLetter = '';
const isFav = !!favs[key]; displaySounds.forEach((s, idx) => {
const isPlaying = lastPlayed === s.name; const ch = s.name.charAt(0).toUpperCase();
const isNew = s.isRecent || s.badges?.includes('new'); const letter = /[A-Z]/.test(ch) ? ch : '#';
const initial = s.name.charAt(0).toUpperCase(); if (letter !== currentLetter) {
const showInitial = firstOfInitial.has(idx); currentLetter = letter;
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; groups.push({ letter, sounds: [] });
}
groups[groups.length - 1].sounds.push({ sound: s, globalIdx: idx });
});
return ( return groups.map(group => (
<div <React.Fragment key={group.letter}>
key={key} <div className="category-header">
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`} <span className="category-letter">{group.letter}</span>
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }} <span className="category-count">{group.sounds.length} Sound{group.sounds.length !== 1 ? 's' : ''}</span>
onClick={e => { <span className="category-line" />
const card = e.currentTarget; </div>
const rect = card.getBoundingClientRect(); <div className="sound-grid">
const ripple = document.createElement('div'); {group.sounds.map(({ sound: s, globalIdx: idx }) => {
ripple.className = 'ripple'; const key = s.relativePath ?? s.fileName;
const sz = Math.max(rect.width, rect.height); const isFav = !!favs[key];
ripple.style.width = ripple.style.height = sz + 'px'; const isPlaying = lastPlayed === s.name;
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; const isNew = s.isRecent || s.badges?.includes('new');
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; const initial = s.name.charAt(0).toUpperCase();
card.appendChild(ripple); const showInitial = firstOfInitial.has(idx);
setTimeout(() => ripple.remove(), 500); const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
handlePlay(s);
}} return (
onContextMenu={e => { <div
e.preventDefault(); key={key}
e.stopPropagation(); className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
setCtxMenu({ style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
x: Math.min(e.clientX, window.innerWidth - 170), onClick={e => {
y: Math.min(e.clientY, window.innerHeight - 140), const card = e.currentTarget;
sound: s, const rect = card.getBoundingClientRect();
}); const ripple = document.createElement('div');
}} ripple.className = 'ripple';
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} const sz = Math.max(rect.width, rect.height);
> ripple.style.width = ripple.style.height = sz + 'px';
{isNew && <span className="new-badge">NEU</span>} ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
<span ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
className={`fav-star ${isFav ? 'active' : ''}`} card.appendChild(ripple);
onClick={e => { e.stopPropagation(); toggleFav(key); }} setTimeout(() => ripple.remove(), 500);
> handlePlay(s);
<span className="material-icons fav-icon">{isFav ? 'star' : 'star_border'}</span> }}
</span> onContextMenu={e => {
{showInitial && <span className="sound-emoji" style={{ color: folderColor }}>{initial}</span>} e.preventDefault();
<span className="sound-name">{s.name}</span> e.stopPropagation();
{s.folder && <span className="sound-duration">{s.folder}</span>} setCtxMenu({
<div className="playing-indicator"> x: Math.min(e.clientX, window.innerWidth - 170),
<div className="wave-bar" /><div className="wave-bar" /> y: Math.min(e.clientY, window.innerHeight - 140),
<div className="wave-bar" /><div className="wave-bar" /> sound: s,
</div> });
</div> }}
); title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
})} >
</div> {isNew && <span className="new-badge">NEU</span>}
)} <span
</main> className={`fav-star ${isFav ? 'active' : ''}`}
onClick={e => { e.stopPropagation(); toggleFav(key); }}
>
<span className="material-icons fav-icon">{isFav ? 'star' : 'star_border'}</span>
</span>
{showInitial && <span className="sound-emoji" style={{ color: folderColor }}>{initial}</span>}
<span className="sound-name">{s.name}</span>
{s.folder && <span className="sound-duration">{s.folder}</span>}
<div className="playing-indicator">
<div className="wave-bar" /><div className="wave-bar" />
<div className="wave-bar" /><div className="wave-bar" />
</div>
</div>
);
})}
</div>
</React.Fragment>
));
})()}
</div>
{/* ═══ CONTEXT MENU ═══ */} {/* ═══ CONTEXT MENU ═══ */}
{ctxMenu && ( {ctxMenu && (
@ -1447,21 +1418,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
<span className="material-icons" style={{ fontSize: 18 }}>close</span> <span className="material-icons" style={{ fontSize: 18 }}>close</span>
</button> </button>
</h3> </h3>
{!isAdmin ? (
<div>
<div className="admin-field">
<label>Passwort</label>
<input
type="password"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
placeholder="Admin-Passwort..."
/>
</div>
<button className="admin-btn-action primary" onClick={handleAdminLogin}>Login</button>
</div>
) : (
<div className="admin-shell"> <div className="admin-shell">
<div className="admin-header-row"> <div className="admin-header-row">
<p className="admin-status">Eingeloggt als Admin</p> <p className="admin-status">Eingeloggt als Admin</p>
@ -1473,7 +1429,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
> >
Aktualisieren Aktualisieren
</button> </button>
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
</div> </div>
</div> </div>
@ -1585,7 +1540,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
)} )}
@ -1713,7 +1667,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
{dropPhase === 'naming' && ( {dropPhase === 'naming' && (
<div className="dl-modal-actions"> <div className="dl-modal-actions">
<button className="dl-modal-cancel" onClick={handleDropSkip}> <button className="dl-modal-cancel" onClick={handleDropSkip}>
{dropFiles.length > 1 ? 'Überspringen' : 'Abbrechen'} {dropFiles.length > 1 ? '\u00dcberspringen' : 'Abbrechen'}
</button> </button>
<button className="dl-modal-submit" onClick={() => void handleDropConfirm()}> <button className="dl-modal-submit" onClick={() => void handleDropConfirm()}>
<span className="material-icons" style={{ fontSize: 16 }}>upload</span> <span className="material-icons" style={{ fontSize: 16 }}>upload</span>

File diff suppressed because it is too large Load diff

View file

@ -46,23 +46,22 @@ function formatElapsed(startedAt: string): string {
// ── Quality Presets ── // ── Quality Presets ──
const QUALITY_PRESETS = [ const QUALITY_PRESETS = [
{ label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 }, { label: 'Niedrig \u00B7 4 Mbit \u00B7 60fps', fps: 60, bitrate: 4_000_000 },
{ label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 }, { label: 'Mittel \u00B7 8 Mbit \u00B7 60fps', fps: 60, bitrate: 8_000_000 },
{ label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 }, { label: 'Hoch \u00B7 14 Mbit \u00B7 60fps', fps: 60, bitrate: 14_000_000 },
{ label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 }, { label: 'Ultra \u00B7 25 Mbit \u00B7 60fps', fps: 60, bitrate: 25_000_000 },
{ label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 }, { label: 'Max \u00B7 50 Mbit \u00B7 165fps', fps: 165, bitrate: 50_000_000 },
{ label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 },
] as const; ] as const;
// ── Component ── // ── Component ──
export default function StreamingTab({ data }: { data: any }) { export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) {
// ── State ── // ── State ──
const [streams, setStreams] = useState<StreamInfo[]>([]); const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
const [streamTitle, setStreamTitle] = useState('Screen Share'); const [streamTitle, setStreamTitle] = useState('Screen Share');
const [streamPassword, setStreamPassword] = useState(''); const [streamPassword, setStreamPassword] = useState('');
const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60 const [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [joinModal, setJoinModal] = useState<JoinModal | null>(null); const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
const [myStreamId, setMyStreamId] = useState<string | null>(null); const [myStreamId, setMyStreamId] = useState<string | null>(null);
@ -75,9 +74,7 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Admin / Notification Config ── // ── Admin / Notification Config ──
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const isAdmin = isAdminProp;
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
const [availableChannels, setAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]); const [availableChannels, setAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]);
const [notifyConfig, setNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]); const [notifyConfig, setNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]);
const [configLoading, setConfigLoading] = useState(false); const [configLoading, setConfigLoading] = useState(false);
@ -100,7 +97,7 @@ export default function StreamingTab({ data }: { data: any }) {
// Refs that mirror state (avoid stale closures in WS handler) // Refs that mirror state (avoid stale closures in WS handler)
const isBroadcastingRef = useRef(false); const isBroadcastingRef = useRef(false);
const viewingRef = useRef<ViewState | null>(null); const viewingRef = useRef<ViewState | null>(null);
const qualityRef = useRef<typeof QUALITY_PRESETS[number]>(QUALITY_PRESETS[2]); const qualityRef = useRef<typeof QUALITY_PRESETS[number]>(QUALITY_PRESETS[1]);
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]); useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
useEffect(() => { viewingRef.current = viewing; }, [viewing]); useEffect(() => { viewingRef.current = viewing; }, [viewing]);
useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]); useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]);
@ -138,12 +135,8 @@ export default function StreamingTab({ data }: { data: any }) {
return () => document.removeEventListener('click', handler); return () => document.removeEventListener('click', handler);
}, [openMenu]); }, [openMenu]);
// Check admin status on mount // Load notification bot status on mount
useEffect(() => { useEffect(() => {
fetch('/api/notifications/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
fetch('/api/notifications/status') fetch('/api/notifications/status')
.then(r => r.json()) .then(r => r.json())
.then(d => setNotifyStatus(d)) .then(d => setNotifyStatus(d))
@ -422,7 +415,7 @@ export default function StreamingTab({ data }: { data: any }) {
try { try {
const q = qualityRef.current; const q = qualityRef.current;
const stream = await navigator.mediaDevices.getDisplayMedia({ const stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } }, video: { frameRate: { ideal: q.fps } },
audio: true, audio: true,
}); });
localStreamRef.current = stream; localStreamRef.current = stream;
@ -610,34 +603,6 @@ export default function StreamingTab({ data }: { data: any }) {
setOpenMenu(null); setOpenMenu(null);
}, [buildStreamLink]); }, [buildStreamLink]);
// ── Admin functions ──
const adminLogin = useCallback(async () => {
setAdminError('');
try {
const resp = await fetch('/api/notifications/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
loadNotifyConfig();
} else {
const d = await resp.json();
setAdminError(d.error || 'Fehler');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}, [adminPwd]);
const adminLogout = useCallback(async () => {
await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
setShowAdmin(false);
}, []);
const loadNotifyConfig = useCallback(async () => { const loadNotifyConfig = useCallback(async () => {
setConfigLoading(true); setConfigLoading(true);
@ -754,39 +719,50 @@ export default function StreamingTab({ data }: { data: any }) {
)} )}
<div className="stream-topbar"> <div className="stream-topbar">
<input <label className="stream-field">
className="stream-input stream-input-name" <span className="stream-field-label">Name</span>
placeholder="Dein Name" <input
value={userName} className="stream-input stream-input-name"
onChange={e => setUserName(e.target.value)} placeholder="Dein Name"
disabled={isBroadcasting} value={userName}
/> onChange={e => setUserName(e.target.value)}
<input disabled={isBroadcasting}
className="stream-input stream-input-title" />
placeholder="Stream-Titel" </label>
value={streamTitle} <label className="stream-field stream-field-grow">
onChange={e => setStreamTitle(e.target.value)} <span className="stream-field-label">Titel</span>
disabled={isBroadcasting} <input
/> className="stream-input stream-input-title"
<input placeholder="Stream-Titel"
className="stream-input stream-input-password" value={streamTitle}
type="password" onChange={e => setStreamTitle(e.target.value)}
placeholder="Passwort (optional)" disabled={isBroadcasting}
value={streamPassword} />
onChange={e => setStreamPassword(e.target.value)} </label>
disabled={isBroadcasting} <label className="stream-field">
/> <span className="stream-field-label">Passwort</span>
<select <input
className="stream-select-quality" className="stream-input stream-input-password"
value={qualityIdx} type="password"
onChange={e => setQualityIdx(Number(e.target.value))} placeholder="optional"
disabled={isBroadcasting} value={streamPassword}
title="Stream-Qualität" onChange={e => setStreamPassword(e.target.value)}
> disabled={isBroadcasting}
{QUALITY_PRESETS.map((p, i) => ( />
<option key={p.label} value={i}>{p.label}</option> </label>
))} <label className="stream-field">
</select> <span className="stream-field-label">Qualit{'\u00E4'}t</span>
<select
className="stream-select-quality"
value={qualityIdx}
onChange={e => setQualityIdx(Number(e.target.value))}
disabled={isBroadcasting}
>
{QUALITY_PRESETS.map((p, i) => (
<option key={p.label} value={i}>{p.label}</option>
))}
</select>
</label>
{isBroadcasting ? ( {isBroadcasting ? (
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}> <button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
{'\u23F9'} Stream beenden {'\u23F9'} Stream beenden
@ -796,9 +772,11 @@ export default function StreamingTab({ data }: { data: any }) {
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'} {starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
</button> </button>
)} )}
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen"> {isAdmin && (
{'\u2699\uFE0F'} <button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
</button> {'\u2699\uFE0F'}
</button>
)}
</div> </div>
{streams.length === 0 && !isBroadcasting ? ( {streams.length === 0 && !isBroadcasting ? (
@ -912,24 +890,6 @@ export default function StreamingTab({ data }: { data: any }) {
<button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button> <button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button>
</div> </div>
{!isAdmin ? (
<div className="stream-admin-login">
<p>Admin-Passwort eingeben:</p>
<div className="stream-admin-login-row">
<input
type="password"
className="stream-input"
placeholder="Passwort"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
autoFocus
/>
<button className="stream-btn" onClick={adminLogin}>Login</button>
</div>
{adminError && <p className="stream-admin-error">{adminError}</p>}
</div>
) : (
<div className="stream-admin-content"> <div className="stream-admin-content">
<div className="stream-admin-toolbar"> <div className="stream-admin-toolbar">
<span className="stream-admin-status"> <span className="stream-admin-status">
@ -937,7 +897,6 @@ export default function StreamingTab({ data }: { data: any }) {
? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></> ? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></>
: <>{'\u26A0\uFE0F'} Bot offline <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>} : <>{'\u26A0\uFE0F'} Bot offline <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>}
</span> </span>
<button className="stream-admin-logout" onClick={adminLogout}>Logout</button>
</div> </div>
{configLoading ? ( {configLoading ? (
@ -993,7 +952,6 @@ export default function StreamingTab({ data }: { data: any }) {
</> </>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -9,12 +9,27 @@
/* ── Top Bar ── */ /* ── Top Bar ── */
.stream-topbar { .stream-topbar {
display: flex; display: flex;
align-items: center; align-items: flex-end;
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.stream-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.stream-field-grow { flex: 1; min-width: 180px; }
.stream-field-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
padding-left: 2px;
}
.stream-input { .stream-input {
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
@ -25,11 +40,13 @@
outline: none; outline: none;
transition: border-color var(--transition); transition: border-color var(--transition);
min-width: 0; min-width: 0;
width: 100%;
box-sizing: border-box;
} }
.stream-input:focus { border-color: var(--accent); } .stream-input:focus { border-color: var(--accent); }
.stream-input::placeholder { color: var(--text-faint); } .stream-input::placeholder { color: var(--text-faint); }
.stream-input-name { width: 150px; } .stream-input-name { width: 150px; }
.stream-input-title { flex: 1; min-width: 180px; } .stream-input-title { width: 100%; }
.stream-btn { .stream-btn {
padding: 10px 20px; padding: 10px 20px;
@ -356,23 +373,25 @@
/* ── Empty state ── */ /* ── Empty state ── */
.stream-empty { .stream-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
color: var(--text-muted); padding: 40px; height: 100%;
} }
.stream-empty-icon { .stream-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 12px; animation: stream-float 3s ease-in-out infinite;
opacity: 0.4; }
@keyframes stream-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.stream-empty h3 { .stream-empty h3 {
font-size: 18px; font-size: 26px; font-weight: 700; color: #f2f3f5;
font-weight: 600; letter-spacing: -0.5px; margin: 0;
color: var(--text-normal);
margin-bottom: 6px;
} }
.stream-empty p { .stream-empty p {
font-size: 14px; font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Error ── */ /* ── Error ── */
@ -405,11 +424,12 @@
/* ── Password input in topbar ── */ /* ── Password input in topbar ── */
.stream-input-password { .stream-input-password {
width: 140px; width: 180px;
} }
.stream-select-quality { .stream-select-quality {
width: 120px; width: 210px;
box-sizing: border-box;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: var(--radius); border-radius: var(--radius);
@ -518,7 +538,7 @@
.stream-admin-panel { .stream-admin-panel {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 6px;
width: 560px; width: 560px;
max-width: 95vw; max-width: 95vw;
max-height: 80vh; max-height: 80vh;
@ -631,7 +651,7 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 4px 10px; padding: 4px 10px;
border-radius: 14px; border-radius: 6px;
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
background: var(--bg-secondary); background: var(--bg-secondary);

View file

@ -161,23 +161,25 @@
/* ── Empty state ── */ /* ── Empty state ── */
.wt-empty { .wt-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
color: var(--text-muted); padding: 40px; height: 100%;
} }
.wt-empty-icon { .wt-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 12px; animation: wt-float 3s ease-in-out infinite;
opacity: 0.4; }
@keyframes wt-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.wt-empty h3 { .wt-empty h3 {
font-size: 18px; font-size: 26px; font-weight: 700; color: #f2f3f5;
font-weight: 600; letter-spacing: -0.5px; margin: 0;
color: var(--text-normal);
margin-bottom: 6px;
} }
.wt-empty p { .wt-empty p {
font-size: 14px; font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Error ── */ /* ── Error ── */
@ -554,9 +556,9 @@
} }
.wt-quality-select { .wt-quality-select {
background: var(--bg-secondary, #2a2a3e); background: var(--bg-secondary, #2a2620);
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #3a3a4e); border: 1px solid var(--border-color, #3a352d);
border-radius: 6px; border-radius: 6px;
padding: 2px 6px; padding: 2px 6px;
font-size: 12px; font-size: 12px;
@ -862,9 +864,9 @@
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
.wt-sync-synced { background: #2ecc71; box-shadow: 0 0 6px rgba(46, 204, 113, 0.5); } .wt-sync-synced { background: #2ecc71; }
.wt-sync-drifting { background: #f1c40f; box-shadow: 0 0 6px rgba(241, 196, 15, 0.5); } .wt-sync-drifting { background: #f1c40f; }
.wt-sync-desynced { background: #e74c3c; box-shadow: 0 0 6px rgba(231, 76, 60, 0.5); } .wt-sync-desynced { background: #e74c3c; }
/* /*
VOTE BUTTONS VOTE BUTTONS

File diff suppressed because it is too large Load diff