Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
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 4050 additions and 2483 deletions

View file

@ -170,6 +170,98 @@ deploy:
-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}}"
- 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:
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 type { PluginContext } from './plugin.js';
/**
* Admin authentication middleware.
* 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();
};
}
// Re-export centralised admin auth
export { requireAdmin } from './auth.js';
/**
* 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 { loadState, getFullState, getStateDiag } from './core/persistence.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 soundboardPlugin from './plugins/soundboard/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) => {
if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; }
const { password } = req.body ?? {};
if (password === ADMIN_PWD) {
const token = signAdminToken(ADMIN_PWD);
setAdminCookie(res, token);
res.json({ ok: true });
} else {
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 ──
app.get('/api/plugins', (_req, res) => {

View file

@ -1,6 +1,7 @@
import type express from 'express';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
import fs from 'node:fs';
import path from 'node:path';
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) ──
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 ──
@ -893,37 +866,7 @@ const gameLibraryPlugin: Plugin = {
// Admin endpoints (same auth as soundboard)
// ═══════════════════════════════════════════════════════════════════
const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => {
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 });
});
const requireAdmin = requireAdminFactory(ctx.adminPwd);
// ── GET /api/game-library/admin/profiles ── Alle Profile mit Details
app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => {

View file

@ -1,8 +1,8 @@
import type express from 'express';
import crypto from 'node:crypto';
import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { getState, setState } from '../../core/persistence.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
const NB = '[Notifications]';
@ -26,40 +26,6 @@ let _client: Client | null = null;
let _ctx: PluginContext | null = null;
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) ──
@ -159,33 +125,7 @@ const notificationsPlugin: Plugin = {
},
registerRoutes(app, ctx) {
const requireAdmin = (req: express.Request, res: express.Response, next: () => void): void => {
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 });
});
const requireAdmin = requireAdminFactory(ctx.adminPwd);
// List available text channels (requires admin)
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 type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
// ── Config (env) ──
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);
}
// ── 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 ──
function schedulePartyPlayback(guildId: string, channelId: string) {
@ -775,28 +749,7 @@ const soundboardPlugin: Plugin = {
},
registerRoutes(app: express.Application, ctx: PluginContext) {
const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => {
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')) });
});
const requireAdmin = requireAdminFactory(ctx.adminPwd);
// ── Sounds ──
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" />
<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>" />
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
<script type="module" crossorigin src="/assets/index-CG_5yn3u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BStrUazC.css">
</head>
<body>
<div id="root"></div>

View file

@ -13,7 +13,7 @@ interface PluginInfo {
}
// Plugin tab components
const tabComponents: Record<string, React.FC<{ data: any }>> = {
const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab,
soundboard: SoundboardTab,
lolstats: LolstatsTab,
@ -22,7 +22,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
'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;
}
@ -40,6 +40,21 @@ export default function App() {
const [showVersionModal, setShowVersionModal] = useState(false);
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
const isElectron = !!(window as any).electronAPI?.isElectron;
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';
// Close version modal on Escape
// Close modals on Escape
useEffect(() => {
if (!showVersionModal) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); };
if (!showVersionModal && !showAdminModal) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowVersionModal(false);
setShowAdminModal(false);
}
};
window.addEventListener('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
@ -153,52 +215,103 @@ export default function App() {
'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 (
<div className="hub-app">
<header className="hub-header">
<div className="hub-header-left">
<span className="hub-logo">{'\u{1F3AE}'}</span>
<span className="hub-title">Gaming Hub</span>
<span className={`hub-conn-dot ${connected ? 'online' : ''}`} />
<div className="app-shell" data-accent={accentTheme}>
{/* ===== SIDEBAR ===== */}
<aside className="app-sidebar">
{/* Sidebar Header: Logo + Brand */}
<div className="sidebar-header">
<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>
<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 => (
<button
key={p.name}
className={`hub-tab ${activeTab === p.name ? 'active' : ''}`}
className={`nav-item ${activeTab === p.name ? 'active' : ''}`}
onClick={() => setActiveTab(p.name)}
title={p.description}
>
<span className="hub-tab-icon">{tabIcons[p.name] ?? '\u{1F4E6}'}</span>
<span className="hub-tab-label">{p.name}</span>
<span className="nav-icon">{tabIcons[p.name] || '\u{1F4E6}'}</span>
<span className="nav-label">{p.name}</span>
</button>
))}
</nav>
<div className="hub-header-right">
{!(window as any).electronAPI && (
<a
className="hub-download-btn"
href="/downloads/GamingHub-Setup.exe"
download
title="Desktop App herunterladen"
>
<span className="hub-download-icon">{'\u2B07\uFE0F'}</span>
<span className="hub-download-label">Desktop App</span>
</a>
)}
{/* Accent Theme Picker */}
<div className="sidebar-accent-picker">
{accentSwatches.map(swatch => (
<button
key={swatch.name}
className={`accent-swatch ${accentTheme === swatch.name ? 'active' : ''}`}
style={{ backgroundColor: swatch.color }}
onClick={() => setAccentTheme(swatch.name)}
title={swatch.name.charAt(0).toUpperCase() + swatch.name.slice(1)}
/>
))}
</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
className="hub-refresh-btn"
onClick={() => window.location.reload()}
title="Seite neu laden"
className={`sidebar-settings ${adminLoggedIn ? 'admin-active' : ''}`}
onClick={() => setShowAdminModal(true)}
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>
<span
className="hub-version hub-version-clickable"
<button
className="sidebar-settings"
onClick={() => {
// Status vom Main-Prozess synchronisieren bevor Modal öffnet
if (isElectron) {
const api = (window as any).electronAPI;
const s = api.getUpdateStatus?.();
@ -208,14 +321,50 @@ export default function App() {
}
setShowVersionModal(true);
}}
title="Versionsinformationen"
title="Einstellungen & Version"
>
v{version}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<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>
</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 && (
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
<div className="hub-version-modal" onClick={e => e.stopPropagation()}>
@ -261,13 +410,13 @@ export default function App() {
{updateStatus === 'checking' && (
<div className="hub-version-modal-update-status">
<span className="hub-update-spinner" />
Suche nach Updates
Suche nach Updates...
</div>
)}
{updateStatus === 'downloading' && (
<div className="hub-version-modal-update-status">
<span className="hub-update-spinner" />
Update wird heruntergeladen
Update wird heruntergeladen...
</div>
)}
{updateStatus === 'ready' && (
@ -307,35 +456,46 @@ export default function App() {
</div>
)}
<main className="hub-content">
{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>
{/* ===== ADMIN MODAL ===== */}
{showAdminModal && (
<div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}>
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
{adminLoggedIn ? (
<>
<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>
) : (
/* 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
*/
export default function GameLibraryTab({ data }: { data: any }) {
export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) {
// ── State ──
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
@ -111,11 +111,9 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── Admin state ──
const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const isAdmin = isAdminProp;
const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const [adminError, setAdminError] = useState('');
// ── SSE data sync ──
useEffect(() => {
@ -133,42 +131,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
} catch { /* silent */ }
}, []);
// ── Admin: check login status on mount ──
useEffect(() => {
fetch('/api/game-library/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
}, []);
// ── Admin: login ──
const adminLogin = useCallback(async () => {
setAdminError('');
try {
const resp = await fetch('/api/game-library/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
} else {
const d = await resp.json();
setAdminError(d.error || 'Fehler');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}, [adminPwd]);
// ── Admin: logout ──
const adminLogout = useCallback(async () => {
await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
setShowAdmin(false);
}, []);
// ── Admin: load profiles ──
const loadAdminProfiles = useCallback(async () => {
@ -552,9 +514,11 @@ export default function GameLibraryTab({ data }: { data: any }) {
</button>
)}
<div className="gl-login-bar-spacer" />
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
&#x2699;&#xFE0F;
</button>
{isAdmin && (
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
&#x2699;&#xFE0F;
</button>
)}
</div>
{/* ── Profile Chips ── */}
@ -990,29 +954,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button>
</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-toolbar">
<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-logout-btn" onClick={adminLogout}>Logout</button>
</div>
{adminLoading ? (
@ -1044,7 +989,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
</div>
)}
</div>
)}
</div>
</div>
)}

View file

@ -472,24 +472,30 @@
/* ── Empty state ── */
.gl-empty {
text-align: center;
padding: 60px 20px;
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 16px;
padding: 40px; height: 100%;
}
.gl-empty-icon {
font-size: 48px;
margin-bottom: 16px;
font-size: 64px; line-height: 1;
filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
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 {
color: var(--text-normal);
margin: 0 0 8px;
font-size: 26px; font-weight: 700; color: #f2f3f5;
letter-spacing: -0.5px; margin: 0;
}
.gl-empty p {
color: var(--text-faint);
margin: 0;
font-size: 14px;
font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
}
/* ── Common game playtime chips ── */

View file

@ -456,22 +456,26 @@
}
.lol-empty {
text-align: center;
padding: 60px 20px;
color: var(--text-faint);
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 16px;
padding: 40px; height: 100%;
}
.lol-empty-icon {
font-size: 48px;
margin-bottom: 12px;
font-size: 64px; line-height: 1;
filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
animation: lol-float 3s ease-in-out infinite;
}
@keyframes lol-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.lol-empty h3 {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 16px;
font-size: 26px; font-weight: 700; color: #f2f3f5;
letter-spacing: -0.5px; margin: 0;
}
.lol-empty p {
margin: 0;
font-size: 13px;
font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
}
/* ── Load more ── */

View file

@ -186,24 +186,6 @@ async function apiGetVolume(guildId: string): Promise<number> {
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> {
const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
@ -285,14 +267,6 @@ function apiUploadFileWithName(
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 = [
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
'#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16',
@ -324,13 +298,14 @@ interface VoiceStats {
interface SoundboardTabProps {
data: any;
isAdmin?: boolean;
}
/*
COMPONENT
*/
export default function SoundboardTab({ data }: SoundboardTabProps) {
export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: SoundboardTabProps) {
/* ── Data ── */
const [sounds, setSounds] = useState<Sound[]>([]);
const [total, setTotal] = useState(0);
@ -378,9 +353,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
/* ── Admin ── */
const [isAdmin, setIsAdmin] = useState(false);
const isAdmin = isAdminProp;
const [showAdmin, setShowAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminSounds, setAdminSounds] = useState<Sound[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const [adminQuery, setAdminQuery] = useState('');
@ -521,13 +495,12 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
}
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
try { setIsAdmin(await apiAdminStatus()); } catch { }
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ── Theme (persist only, data-theme is set on .sb-app div) ── */
/* ── Theme (persist — global theming now handled by app-shell) ── */
useEffect(() => {
localStorage.setItem('jb-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 ── */
const displaySounds = useMemo(() => {
@ -968,129 +920,119 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
RENDER
*/
return (
<div className="sb-app" data-theme={theme} ref={sbAppRef}>
<div className="sb-app" ref={sbAppRef}>
{chaosMode && <div className="party-overlay active" />}
{/* ═══ TOPBAR ═══ */}
<header className="topbar">
<div className="topbar-left">
<div className="sb-app-logo">
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</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>
{/* ═══ CONTENT HEADER ═══ */}
<div className="content-header">
<div className="content-header__title">
Soundboard
<span className="sound-count">{totalSoundsDisplay}</span>
</div>
<div className="clock-wrap">
<div className="clock">{clockMain}<span className="clock-seconds">{clockSec}</span></div>
</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>
<div className="content-header__search">
<span className="material-icons" style={{ fontSize: 14 }}>search</span>
<input
className="search-input"
type="text"
placeholder="Suchen..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
{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>
</button>
)}
</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">
<span className="material-icons url-import-icon">
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
@ -1123,113 +1065,120 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</button>
</div>
<div className="toolbar-spacer" />
<div className="volume-control">
<span
className="material-icons vol-icon"
onClick={() => {
const newVol = volume > 0 ? 0 : 0.5;
setVolume(newVol);
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
}}
>
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
</span>
<input
type="range"
className="vol-slider"
min={0}
max={1}
step={0.01}
value={volume}
onChange={e => {
const v = parseFloat(e.target.value);
setVolume(v);
if (guildId) {
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
volDebounceRef.current = setTimeout(() => {
apiSetVolumeLive(guildId, v).catch(() => {});
}, 120);
}
}}
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)}
<div className="toolbar__right">
{/* Volume */}
<div className="volume-control">
<span
className="material-icons vol-icon"
onClick={() => {
const newVol = volume > 0 ? 0 : 0.5;
setVolume(newVol);
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
}}
style={{ cursor: 'pointer' }}
>
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
</span>
<input
type="range"
className="volume-slider"
min={0}
max={1}
step={0.01}
value={volume}
onChange={e => {
const v = parseFloat(e.target.value);
setVolume(v);
if (guildId) {
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
volDebounceRef.current = setTimeout(() => {
apiSetVolumeLive(guildId, v).catch(() => {});
}, 120);
}
}}
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
/>
))}
</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>
<span className="volume-label">{Math.round(volume * 100)}%</span>
</div>
</div>
<div className="analytics-card analytics-wide">
<span className="material-icons analytics-icon">leaderboard</span>
<div className="analytics-copy">
<span className="analytics-label">Most Played</span>
<div className="analytics-top-list">
{analyticsTop.length === 0 ? (
<span className="analytics-muted">Noch keine Plays</span>
) : (
analyticsTop.map((item, idx) => (
<span className="analytics-chip" key={item.relativePath}>
{idx + 1}. {item.name} ({item.count})
</span>
))
)}
</div>
{/* Channel selector */}
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
<button
className={`channel-dropdown__trigger ${channelOpen ? 'open' : ''}`}
onClick={() => setChannelOpen(!channelOpen)}
>
<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 }} />}
<span className="channel-name">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
<span className={`material-icons channel-arrow`} style={{ fontSize: 14 }}>expand_more</span>
</button>
{channelOpen && (
<div className="channel-dropdown__menu" style={{ display: 'block' }}>
{Object.entries(channelsByGuild).map(([guild, chs]) => (
<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>
{/* ═══ 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 ═══ */}
{activeTab === 'all' && visibleFolders.length > 0 && (
<div className="category-strip">
@ -1252,8 +1201,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</div>
)}
{/* ═══ MAIN ═══ */}
<main className="main">
{/* ═══ SOUND GRID ═══ */}
<div className="sound-grid-container">
{displaySounds.length === 0 ? (
<div className="empty-state visible">
<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.'}
</div>
</div>
) : (
<div className="sound-grid">
{displaySounds.map((s, idx) => {
const key = s.relativePath ?? s.fileName;
const isFav = !!favs[key];
const isPlaying = lastPlayed === s.name;
const isNew = s.isRecent || s.badges?.includes('new');
const initial = s.name.charAt(0).toUpperCase();
const showInitial = firstOfInitial.has(idx);
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
) : (() => {
// Group sounds by initial letter for category headers
const groups: { letter: string; sounds: { sound: Sound; globalIdx: number }[] }[] = [];
let currentLetter = '';
displaySounds.forEach((s, idx) => {
const ch = s.name.charAt(0).toUpperCase();
const letter = /[A-Z]/.test(ch) ? ch : '#';
if (letter !== currentLetter) {
currentLetter = letter;
groups.push({ letter, sounds: [] });
}
groups[groups.length - 1].sounds.push({ sound: s, globalIdx: idx });
});
return (
<div
key={key}
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
onClick={e => {
const card = e.currentTarget;
const rect = card.getBoundingClientRect();
const ripple = document.createElement('div');
ripple.className = 'ripple';
const sz = Math.max(rect.width, rect.height);
ripple.style.width = ripple.style.height = sz + 'px';
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
card.appendChild(ripple);
setTimeout(() => ripple.remove(), 500);
handlePlay(s);
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 140),
sound: s,
});
}}
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
>
{isNew && <span className="new-badge">NEU</span>}
<span
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>
)}
</main>
return groups.map(group => (
<React.Fragment key={group.letter}>
<div className="category-header">
<span className="category-letter">{group.letter}</span>
<span className="category-count">{group.sounds.length} Sound{group.sounds.length !== 1 ? 's' : ''}</span>
<span className="category-line" />
</div>
<div className="sound-grid">
{group.sounds.map(({ sound: s, globalIdx: idx }) => {
const key = s.relativePath ?? s.fileName;
const isFav = !!favs[key];
const isPlaying = lastPlayed === s.name;
const isNew = s.isRecent || s.badges?.includes('new');
const initial = s.name.charAt(0).toUpperCase();
const showInitial = firstOfInitial.has(idx);
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
return (
<div
key={key}
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
onClick={e => {
const card = e.currentTarget;
const rect = card.getBoundingClientRect();
const ripple = document.createElement('div');
ripple.className = 'ripple';
const sz = Math.max(rect.width, rect.height);
ripple.style.width = ripple.style.height = sz + 'px';
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
card.appendChild(ripple);
setTimeout(() => ripple.remove(), 500);
handlePlay(s);
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 140),
sound: s,
});
}}
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
>
{isNew && <span className="new-badge">NEU</span>}
<span
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 ═══ */}
{ctxMenu && (
@ -1447,21 +1418,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
</button>
</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-header-row">
<p className="admin-status">Eingeloggt als Admin</p>
@ -1473,7 +1429,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
>
Aktualisieren
</button>
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
</div>
</div>
@ -1585,7 +1540,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)}
</div>
</div>
)}
</div>
</div>
)}
@ -1713,7 +1667,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
{dropPhase === 'naming' && (
<div className="dl-modal-actions">
<button className="dl-modal-cancel" onClick={handleDropSkip}>
{dropFiles.length > 1 ? 'Überspringen' : 'Abbrechen'}
{dropFiles.length > 1 ? '\u00dcberspringen' : 'Abbrechen'}
</button>
<button className="dl-modal-submit" onClick={() => void handleDropConfirm()}>
<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 ──
const QUALITY_PRESETS = [
{ label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 },
{ label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 },
{ label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 },
{ label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 },
{ label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 },
{ label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 },
{ label: 'Niedrig \u00B7 4 Mbit \u00B7 60fps', fps: 60, bitrate: 4_000_000 },
{ label: 'Mittel \u00B7 8 Mbit \u00B7 60fps', fps: 60, bitrate: 8_000_000 },
{ label: 'Hoch \u00B7 14 Mbit \u00B7 60fps', fps: 60, bitrate: 14_000_000 },
{ label: 'Ultra \u00B7 25 Mbit \u00B7 60fps', fps: 60, bitrate: 25_000_000 },
{ label: 'Max \u00B7 50 Mbit \u00B7 165fps', fps: 165, bitrate: 50_000_000 },
] as const;
// ── Component ──
export default function StreamingTab({ data }: { data: any }) {
export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) {
// ── State ──
const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
const [streamTitle, setStreamTitle] = useState('Screen Share');
const [streamPassword, setStreamPassword] = useState('');
const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60
const [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60
const [error, setError] = useState<string | null>(null);
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
const [myStreamId, setMyStreamId] = useState<string | null>(null);
@ -75,9 +74,7 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Admin / Notification Config ──
const [showAdmin, setShowAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
const isAdmin = isAdminProp;
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 [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)
const isBroadcastingRef = useRef(false);
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(() => { viewingRef.current = viewing; }, [viewing]);
useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]);
@ -138,12 +135,8 @@ export default function StreamingTab({ data }: { data: any }) {
return () => document.removeEventListener('click', handler);
}, [openMenu]);
// Check admin status on mount
// Load notification bot status on mount
useEffect(() => {
fetch('/api/notifications/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(d.admin === true))
.catch(() => {});
fetch('/api/notifications/status')
.then(r => r.json())
.then(d => setNotifyStatus(d))
@ -422,7 +415,7 @@ export default function StreamingTab({ data }: { data: any }) {
try {
const q = qualityRef.current;
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } },
video: { frameRate: { ideal: q.fps } },
audio: true,
});
localStreamRef.current = stream;
@ -610,34 +603,6 @@ export default function StreamingTab({ data }: { data: any }) {
setOpenMenu(null);
}, [buildStreamLink]);
// ── Admin functions ──
const adminLogin = useCallback(async () => {
setAdminError('');
try {
const resp = await fetch('/api/notifications/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
loadNotifyConfig();
} else {
const d = await resp.json();
setAdminError(d.error || 'Fehler');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}, [adminPwd]);
const adminLogout = useCallback(async () => {
await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
setShowAdmin(false);
}, []);
const loadNotifyConfig = useCallback(async () => {
setConfigLoading(true);
@ -754,39 +719,50 @@ export default function StreamingTab({ data }: { data: any }) {
)}
<div className="stream-topbar">
<input
className="stream-input stream-input-name"
placeholder="Dein Name"
value={userName}
onChange={e => setUserName(e.target.value)}
disabled={isBroadcasting}
/>
<input
className="stream-input stream-input-title"
placeholder="Stream-Titel"
value={streamTitle}
onChange={e => setStreamTitle(e.target.value)}
disabled={isBroadcasting}
/>
<input
className="stream-input stream-input-password"
type="password"
placeholder="Passwort (optional)"
value={streamPassword}
onChange={e => setStreamPassword(e.target.value)}
disabled={isBroadcasting}
/>
<select
className="stream-select-quality"
value={qualityIdx}
onChange={e => setQualityIdx(Number(e.target.value))}
disabled={isBroadcasting}
title="Stream-Qualität"
>
{QUALITY_PRESETS.map((p, i) => (
<option key={p.label} value={i}>{p.label}</option>
))}
</select>
<label className="stream-field">
<span className="stream-field-label">Name</span>
<input
className="stream-input stream-input-name"
placeholder="Dein Name"
value={userName}
onChange={e => setUserName(e.target.value)}
disabled={isBroadcasting}
/>
</label>
<label className="stream-field stream-field-grow">
<span className="stream-field-label">Titel</span>
<input
className="stream-input stream-input-title"
placeholder="Stream-Titel"
value={streamTitle}
onChange={e => setStreamTitle(e.target.value)}
disabled={isBroadcasting}
/>
</label>
<label className="stream-field">
<span className="stream-field-label">Passwort</span>
<input
className="stream-input stream-input-password"
type="password"
placeholder="optional"
value={streamPassword}
onChange={e => setStreamPassword(e.target.value)}
disabled={isBroadcasting}
/>
</label>
<label className="stream-field">
<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 ? (
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
{'\u23F9'} Stream beenden
@ -796,9 +772,11 @@ export default function StreamingTab({ data }: { data: any }) {
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
</button>
)}
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
{'\u2699\uFE0F'}
</button>
{isAdmin && (
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
{'\u2699\uFE0F'}
</button>
)}
</div>
{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>
</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-toolbar">
<span className="stream-admin-status">
@ -937,7 +897,6 @@ export default function StreamingTab({ data }: { data: any }) {
? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></>
: <>{'\u26A0\uFE0F'} Bot offline <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>}
</span>
<button className="stream-admin-logout" onClick={adminLogout}>Logout</button>
</div>
{configLoading ? (
@ -993,7 +952,6 @@ export default function StreamingTab({ data }: { data: any }) {
</>
)}
</div>
)}
</div>
</div>
)}

View file

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

View file

@ -161,23 +161,26 @@
/* ── Empty state ── */
.wt-empty {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 16px;
padding: 40px; height: 100%;
}
.wt-empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.4;
font-size: 64px; line-height: 1;
filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
animation: wt-float 3s ease-in-out infinite;
}
@keyframes wt-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.wt-empty h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-normal);
margin-bottom: 6px;
font-size: 26px; font-weight: 700; color: #f2f3f5;
letter-spacing: -0.5px; margin: 0;
}
.wt-empty p {
font-size: 14px;
font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
}
/* ── Error ── */

File diff suppressed because it is too large Load diff