Compare commits
11 commits
4e0d691aa1
...
7d89ba6978
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d89ba6978 | ||
|
|
970e1c2bc1 | ||
|
|
41d2c0e570 | ||
|
|
c3ac4432ca | ||
|
|
7e09575009 | ||
|
|
041557c885 | ||
|
|
3127d31355 | ||
|
|
10fcde125d | ||
|
|
f27093b87a | ||
|
|
b3080fb763 | ||
|
|
8abe0775a5 |
21 changed files with 4080 additions and 2606 deletions
|
|
@ -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
61
server/src/core/auth.ts
Normal 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 };
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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
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
1
web/dist/assets/index-DEfJ3Ric.css
vendored
1
web/dist/assets/index-DEfJ3Ric.css
vendored
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
|
|
@ -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>
|
||||||
|
|
|
||||||
294
web/src/App.tsx
294
web/src/App.tsx
|
|
@ -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"
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="hub-refresh-btn"
|
key={swatch.name}
|
||||||
onClick={() => window.location.reload()}
|
className={`accent-swatch ${accentTheme === swatch.name ? 'active' : ''}`}
|
||||||
title="Seite neu laden"
|
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={`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>
|
</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>
|
||||||
|
</div>
|
||||||
|
<button className="hub-admin-modal-logout" onClick={handleAdminLogout}>
|
||||||
|
Ausloggen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Render ALL tabs, hide inactive ones to preserve state.
|
<>
|
||||||
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
|
<div className="hub-admin-modal-title">{'\u{1F511}'} Admin Login</div>
|
||||||
plugins.map(p => {
|
<div className="hub-admin-modal-subtitle">Passwort eingeben um Einstellungen freizuschalten</div>
|
||||||
const Comp = tabComponents[p.name];
|
{adminError && <div className="hub-admin-modal-error">{adminError}</div>}
|
||||||
if (!Comp) return null;
|
<input
|
||||||
const isActive = activeTab === p.name;
|
className="hub-admin-modal-input"
|
||||||
return (
|
type="password"
|
||||||
<div
|
placeholder="Passwort"
|
||||||
key={p.name}
|
value={adminPassword}
|
||||||
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
|
onChange={e => setAdminPassword(e.target.value)}
|
||||||
style={isActive
|
onKeyDown={e => { if (e.key === 'Enter') handleAdminLogin(); }}
|
||||||
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
|
autoFocus
|
||||||
: { display: 'none' }
|
/>
|
||||||
}
|
<button className="hub-admin-modal-login" onClick={handleAdminLogin}>
|
||||||
>
|
Login
|
||||||
<Comp data={pluginData[p.name] || {}} />
|
</button>
|
||||||
</div>
|
</>
|
||||||
);
|
)}
|
||||||
})
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
{isAdmin && (
|
||||||
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
|
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
|
||||||
⚙️
|
⚙️
|
||||||
</button>
|
</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)}>✕</button>
|
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>✕</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">✅ Eingeloggt als Admin</span>
|
<span className="gl-admin-status-text">✅ Eingeloggt als Admin</span>
|
||||||
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>↻ Aktualisieren</button>
|
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>↻ 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,8 +1065,8 @@ 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"
|
||||||
|
|
@ -1133,12 +1075,13 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
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'}
|
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="vol-slider"
|
className="volume-slider"
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
|
|
@ -1155,30 +1098,49 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
}}
|
}}
|
||||||
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>
|
<span className="volume-label">{Math.round(volume * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="tb-btn random" onClick={handleRandom} title="Zufaelliger Sound">
|
{/* Channel selector */}
|
||||||
<span className="material-icons tb-icon">shuffle</span>
|
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
|
||||||
Random
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`tb-btn party ${chaosMode ? 'active' : ''}`}
|
className={`channel-dropdown__trigger ${channelOpen ? 'open' : ''}`}
|
||||||
onClick={toggleParty}
|
onClick={() => setChannelOpen(!channelOpen)}
|
||||||
title="Party Mode"
|
|
||||||
>
|
>
|
||||||
<span className="material-icons tb-icon">{chaosMode ? 'celebration' : 'auto_awesome'}</span>
|
<span className="material-icons channel-icon" style={{ fontSize: 16 }}>headset</span>
|
||||||
{chaosMode ? 'Party!' : 'Party'}
|
{selected && <span className="channel-status" style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--success)', flexShrink: 0 }} />}
|
||||||
</button>
|
<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 className="tb-btn stop" onClick={handleStop} title="Alle stoppen">
|
|
||||||
<span className="material-icons tb-icon">stop</span>
|
|
||||||
Stop
|
|
||||||
</button>
|
</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">
|
<div className="size-control" title="Button-Groesse">
|
||||||
<span className="material-icons sc-icon">grid_view</span>
|
<span className="material-icons sc-icon" style={{ fontSize: 16 }}>grid_view</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="size-slider"
|
className="size-slider"
|
||||||
|
|
@ -1188,47 +1150,34 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
onChange={e => setCardSize(parseInt(e.target.value))}
|
onChange={e => setCardSize(parseInt(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="theme-selector">
|
{/* ═══ MOST PLAYED / ANALYTICS ═══ */}
|
||||||
{THEMES.map(t => (
|
{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
|
<div
|
||||||
key={t.id}
|
className="mp-chip"
|
||||||
className={`theme-dot ${theme === t.id ? 'active' : ''}`}
|
key={item.relativePath}
|
||||||
style={{ background: t.color }}
|
onClick={() => {
|
||||||
title={t.label}
|
const found = sounds.find(s => (s.relativePath ?? s.fileName) === item.relativePath);
|
||||||
onClick={() => setTheme(t.id)}
|
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>
|
||||||
</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 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══ FOLDER CHIPS ═══ */}
|
{/* ═══ FOLDER CHIPS ═══ */}
|
||||||
{activeTab === 'all' && visibleFolders.length > 0 && (
|
{activeTab === 'all' && visibleFolders.length > 0 && (
|
||||||
|
|
@ -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,9 +1219,29 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
: 'Hier gibt\'s noch nichts zu hoeren.'}
|
: 'Hier gibt\'s noch nichts zu hoeren.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
|
// 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 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">
|
<div className="sound-grid">
|
||||||
{displaySounds.map((s, idx) => {
|
{group.sounds.map(({ sound: s, globalIdx: idx }) => {
|
||||||
const key = s.relativePath ?? s.fileName;
|
const key = s.relativePath ?? s.fileName;
|
||||||
const isFav = !!favs[key];
|
const isFav = !!favs[key];
|
||||||
const isPlaying = lastPlayed === s.name;
|
const isPlaying = lastPlayed === s.name;
|
||||||
|
|
@ -1328,8 +1297,10 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</React.Fragment>
|
||||||
</main>
|
));
|
||||||
|
})()}
|
||||||
|
</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
|
|
@ -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,6 +719,8 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="stream-topbar">
|
<div className="stream-topbar">
|
||||||
|
<label className="stream-field">
|
||||||
|
<span className="stream-field-label">Name</span>
|
||||||
<input
|
<input
|
||||||
className="stream-input stream-input-name"
|
className="stream-input stream-input-name"
|
||||||
placeholder="Dein Name"
|
placeholder="Dein Name"
|
||||||
|
|
@ -761,6 +728,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
onChange={e => setUserName(e.target.value)}
|
onChange={e => setUserName(e.target.value)}
|
||||||
disabled={isBroadcasting}
|
disabled={isBroadcasting}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="stream-field stream-field-grow">
|
||||||
|
<span className="stream-field-label">Titel</span>
|
||||||
<input
|
<input
|
||||||
className="stream-input stream-input-title"
|
className="stream-input stream-input-title"
|
||||||
placeholder="Stream-Titel"
|
placeholder="Stream-Titel"
|
||||||
|
|
@ -768,25 +738,31 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
onChange={e => setStreamTitle(e.target.value)}
|
onChange={e => setStreamTitle(e.target.value)}
|
||||||
disabled={isBroadcasting}
|
disabled={isBroadcasting}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="stream-field">
|
||||||
|
<span className="stream-field-label">Passwort</span>
|
||||||
<input
|
<input
|
||||||
className="stream-input stream-input-password"
|
className="stream-input stream-input-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Passwort (optional)"
|
placeholder="optional"
|
||||||
value={streamPassword}
|
value={streamPassword}
|
||||||
onChange={e => setStreamPassword(e.target.value)}
|
onChange={e => setStreamPassword(e.target.value)}
|
||||||
disabled={isBroadcasting}
|
disabled={isBroadcasting}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="stream-field">
|
||||||
|
<span className="stream-field-label">Qualit{'\u00E4'}t</span>
|
||||||
<select
|
<select
|
||||||
className="stream-select-quality"
|
className="stream-select-quality"
|
||||||
value={qualityIdx}
|
value={qualityIdx}
|
||||||
onChange={e => setQualityIdx(Number(e.target.value))}
|
onChange={e => setQualityIdx(Number(e.target.value))}
|
||||||
disabled={isBroadcasting}
|
disabled={isBroadcasting}
|
||||||
title="Stream-Qualität"
|
|
||||||
>
|
>
|
||||||
{QUALITY_PRESETS.map((p, i) => (
|
{QUALITY_PRESETS.map((p, i) => (
|
||||||
<option key={p.label} value={i}>{p.label}</option>
|
<option key={p.label} value={i}>{p.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
|
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
|
||||||
{'\u2699\uFE0F'}
|
{'\u2699\uFE0F'}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2721
web/src/styles.css
2721
web/src/styles.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue