Compare commits
42 commits
7d89ba6978
...
4e0d691aa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0d691aa1 | ||
|
|
32918e0a7a | ||
|
|
7f0b17291f | ||
|
|
4478ac6a6a | ||
|
|
7136aafec6 | ||
|
|
e1e2b9a1d8 | ||
|
|
1da6c76017 | ||
|
|
694f4371ce | ||
|
|
b8e4139a91 | ||
|
|
7fe9a16cd8 | ||
|
|
24e8a6b3f7 | ||
|
|
bf69827dbd | ||
|
|
d135aab6dc | ||
|
|
aa998c9b44 | ||
|
|
81c73407a0 | ||
|
|
6224db68b3 | ||
|
|
99d69f30ba | ||
|
|
a7e8407996 | ||
| 5ff0dad282 | |||
| 710081fe21 | |||
| 7e1b4e7860 | |||
| 9c483cedea | |||
| 5796a6d620 | |||
|
|
c24b4c5d9e | ||
|
|
7ed6b81584 | ||
|
|
25e47fb093 | ||
|
|
0bd31d93a8 | ||
|
|
c0956fbc7e | ||
|
|
8951f46536 | ||
|
|
354a9cd977 | ||
|
|
3f175ca02c | ||
|
|
4b23d013f9 | ||
|
|
e9931d82af | ||
|
|
bccfee3de2 | ||
|
|
b556863f52 | ||
|
|
65a1d6e869 | ||
|
|
e54f240523 | ||
|
|
a99dc4211c | ||
|
|
1bd0fa14bc | ||
|
|
b2f772208f | ||
|
|
966664d3a7 | ||
|
|
39e180aad4 |
26 changed files with 8199 additions and 5680 deletions
153
.forgejo/workflows/build-deploy.yml
Normal file
153
.forgejo/workflows/build-deploy.yml
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, nightly, feature/nightly]
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.adriahub.de
|
||||
REGISTRY_MIRROR: forgejo.daddelolymp.de
|
||||
IMAGE: root/gaming-hub
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"http://root:${{ secrets.PUSH_TOKEN }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" .
|
||||
|
||||
- name: Determine version and tag
|
||||
id: vars
|
||||
run: |
|
||||
VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
BRANCH="${GITHUB_REF_NAME}"
|
||||
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
TAG="main"
|
||||
CHANNEL="stable"
|
||||
elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then
|
||||
TAG="nightly"
|
||||
VERSION="${VERSION}-nightly"
|
||||
CHANNEL="nightly"
|
||||
else
|
||||
TAG=$(echo "$BRANCH" | sed 's/\//-/g')
|
||||
VERSION="${VERSION}-dev"
|
||||
CHANNEL="dev"
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg "VITE_BUILD_CHANNEL=${{ steps.vars.outputs.channel }}" \
|
||||
--build-arg "VITE_APP_VERSION=${{ steps.vars.outputs.version }}" \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \
|
||||
.
|
||||
|
||||
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
fi
|
||||
|
||||
- name: Push to registry (adriahub)
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
|
||||
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
fi
|
||||
|
||||
- name: Mirror to registry (daddelolymp)
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_DADDELOLYMP_PASSWORD }}" | docker login ${{ env.REGISTRY_MIRROR }} -u root --password-stdin
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \
|
||||
${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
|
||||
docker push ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
|
||||
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest \
|
||||
${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:latest
|
||||
docker push ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:latest
|
||||
fi
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.ref_name == 'main'
|
||||
container:
|
||||
image: docker:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
steps:
|
||||
- name: Deploy container
|
||||
run: |
|
||||
DEPLOY_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE }}:latest"
|
||||
CONTAINER_NAME="gaming-hub"
|
||||
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin
|
||||
docker pull "$DEPLOY_IMAGE"
|
||||
docker stop "$CONTAINER_NAME" || true
|
||||
docker rm "$CONTAINER_NAME" || true
|
||||
|
||||
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="${{ secrets.GAMING_HUB_ADMIN_PWD }}" \
|
||||
-e PCM_CACHE_MAX_MB=2048 \
|
||||
-e DISCORD_TOKEN_JUKEBOX="${{ secrets.DISCORD_TOKEN_JUKEBOX }}" \
|
||||
-e DISCORD_TOKEN_RADIO="${{ secrets.DISCORD_TOKEN_RADIO }}" \
|
||||
-e DISCORD_TOKEN_NOTIFICATIONS="${{ secrets.DISCORD_TOKEN_NOTIFICATIONS }}" \
|
||||
-e PUBLIC_URL="${{ secrets.PUBLIC_URL }}" \
|
||||
-e STEAM_API_KEY="${{ secrets.STEAM_API_KEY }}" \
|
||||
-e DISCORD_CLIENT_ID="${{ secrets.DISCORD_CLIENT_ID }}" \
|
||||
-e DISCORD_CLIENT_SECRET="${{ secrets.DISCORD_CLIENT_SECRET }}" \
|
||||
-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"
|
||||
docker image prune -f || true
|
||||
|
||||
bump-version:
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy
|
||||
if: github.ref_name == 'main' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
container:
|
||||
image: alpine/git:latest
|
||||
steps:
|
||||
- name: Checkout and bump
|
||||
run: |
|
||||
git clone --branch main --depth 5 \
|
||||
"http://root:${{ secrets.PUSH_TOKEN }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" repo
|
||||
cd repo
|
||||
git config user.name "Forgejo CI"
|
||||
git config user.email "ci@adriahub.de"
|
||||
|
||||
VERSION=$(cat VERSION)
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
NEXT_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}"
|
||||
|
||||
echo "$NEXT_VERSION" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "v${NEXT_VERSION} [skip ci]"
|
||||
git push origin main
|
||||
|
|
@ -170,6 +170,8 @@ deploy:
|
|||
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
|
||||
"$DEPLOY_IMAGE"
|
||||
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
|
||||
- echo "[Deploy] Cleaning up dangling images..."
|
||||
- docker image prune -f || true
|
||||
|
||||
bump-version:
|
||||
stage: bump-version
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.8.2
|
||||
1.8.18
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ function createWindow() {
|
|||
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } });
|
||||
if (sources.length === 0) {
|
||||
callback({});
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -155,16 +155,31 @@ h2{font-size:16px;margin-bottom:12px;color:#ccc}
|
|||
.item:hover{border-color:#7c5cff;transform:scale(1.03)}
|
||||
.item img{width:100%;height:120px;object-fit:cover;display:block;background:#111}
|
||||
.item .label{padding:8px 10px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cancel-row{text-align:center;margin-top:14px}
|
||||
.bottom-row{display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding:0 4px}
|
||||
.audio-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
|
||||
.audio-toggle input{display:none}
|
||||
.switch{width:36px;height:20px;background:#3a3a4e;border-radius:10px;position:relative;transition:background .2s}
|
||||
.switch::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#888;transition:transform .2s,background .2s}
|
||||
.audio-toggle input:checked+.switch{background:#7c5cff}
|
||||
.audio-toggle input:checked+.switch::after{transform:translateX(16px);background:#fff}
|
||||
.audio-label{font-size:13px;color:#aaa}
|
||||
.cancel-btn{background:#3a3a4e;color:#e0e0e0;border:none;padding:8px 24px;border-radius:6px;cursor:pointer;font-size:14px}
|
||||
.cancel-btn:hover{background:#4a4a5e}
|
||||
</style></head><body>
|
||||
<h2>Bildschirm oder Fenster w\\u00e4hlen</h2>
|
||||
<h2>Bildschirm oder Fenster wählen</h2>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div class="cancel-row"><button class="cancel-btn" id="cancelBtn">Abbrechen</button></div>
|
||||
<div class="bottom-row">
|
||||
<label class="audio-toggle">
|
||||
<input type="checkbox" id="audioToggle" checked>
|
||||
<span class="switch"></span>
|
||||
<span class="audio-label">\u{1F50A} Audio mitstreamen</span>
|
||||
</label>
|
||||
<button class="cancel-btn" id="cancelBtn">Abbrechen</button>
|
||||
</div>
|
||||
<script>
|
||||
const sources = ${JSON.stringify(sourceData)};
|
||||
const grid = document.getElementById('grid');
|
||||
const audioToggle = document.getElementById('audioToggle');
|
||||
sources.forEach(s => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item';
|
||||
|
|
@ -176,7 +191,7 @@ sources.forEach(s => {
|
|||
label.textContent = s.name;
|
||||
div.appendChild(label);
|
||||
div.addEventListener('click', () => {
|
||||
require('electron').ipcRenderer.send('${PICKER_CHANNEL}', s.id);
|
||||
require('electron').ipcRenderer.send('${PICKER_CHANNEL}', { id: s.id, audio: audioToggle.checked });
|
||||
});
|
||||
grid.appendChild(div);
|
||||
});
|
||||
|
|
@ -209,22 +224,24 @@ document.getElementById('cancelBtn').addEventListener('click', () => {
|
|||
|
||||
let resolved = false;
|
||||
|
||||
const onPickerResult = (_event, selectedId) => {
|
||||
const onPickerResult = (_event, selection) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
ipcMain.removeListener(PICKER_CHANNEL, onPickerResult);
|
||||
picker.close();
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
|
||||
if (!selectedId) {
|
||||
callback({});
|
||||
if (!selection) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const selectedId = typeof selection === 'string' ? selection : selection.id;
|
||||
const withAudio = typeof selection === 'object' ? selection.audio : true;
|
||||
const chosen = sources.find(s => s.id === selectedId);
|
||||
if (chosen) {
|
||||
callback({ video: chosen, audio: 'loopback' });
|
||||
callback(withAudio ? { video: chosen, audio: 'loopback' } : { video: chosen });
|
||||
} else {
|
||||
callback({});
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -235,7 +252,7 @@ document.getElementById('cancelBtn').addEventListener('click', () => {
|
|||
if (!resolved) {
|
||||
resolved = true;
|
||||
ipcMain.removeListener(PICKER_CHANNEL, onPickerResult);
|
||||
callback({});
|
||||
callback();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gaming-hub-desktop",
|
||||
"productName": "Gaming Hub",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.16",
|
||||
"description": "Gaming Hub Desktop App mit Ad-Blocker",
|
||||
"author": "Gaming Hub",
|
||||
"main": "main.js",
|
||||
|
|
|
|||
359
server/src/core/discord-auth.ts
Normal file
359
server/src/core/discord-auth.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Unified Authentication: Discord OAuth2, Steam OpenID 2.0, Admin
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import crypto from 'node:crypto';
|
||||
import type express from 'express';
|
||||
|
||||
// ── Config ──
|
||||
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? '';
|
||||
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? '';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
const DISCORD_AUTH_URL = 'https://discord.com/oauth2/authorize';
|
||||
const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
|
||||
const STEAM_API_KEY = process.env.STEAM_API_KEY ?? '';
|
||||
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds
|
||||
const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds
|
||||
|
||||
// ── Types ──
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar: string | null;
|
||||
global_name: string | null;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
provider: 'discord' | 'steam' | 'admin';
|
||||
discordId?: string;
|
||||
steamId?: string;
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
globalName?: string | null;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** Returns the generic user ID regardless of provider (discordId, steam:steamId, or 'admin') */
|
||||
export function getUserId(session: UserSession): string | null {
|
||||
if (session.discordId) return session.discordId;
|
||||
if (session.steamId) return `steam:${session.steamId}`;
|
||||
if (session.provider === 'admin') return 'admin';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function b64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function readCookie(req: express.Request, key: string): string | undefined {
|
||||
const c = req.headers.cookie;
|
||||
if (!c) return undefined;
|
||||
for (const part of c.split(';')) {
|
||||
const [k, v] = part.trim().split('=');
|
||||
if (k === key) return decodeURIComponent(v || '');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Session Token (HMAC-SHA256) ──
|
||||
// Uses ADMIN_PWD as base secret, with a salt to differentiate from admin tokens
|
||||
const SESSION_SECRET = (process.env.ADMIN_PWD ?? '') + ':hub_session_v1';
|
||||
|
||||
export function signSession(session: UserSession): string {
|
||||
const body = b64url(JSON.stringify(session));
|
||||
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifySession(token: string | undefined): UserSession | null {
|
||||
if (!token) return null;
|
||||
const [body, sig] = token.split('.');
|
||||
if (!body || !sig) return null;
|
||||
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
|
||||
if (expected !== sig) return null;
|
||||
try {
|
||||
const session = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as UserSession;
|
||||
if (typeof session.exp === 'number' && Date.now() < session.exp) return session;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function getSession(req: express.Request): UserSession | null {
|
||||
return verifySession(readCookie(req, 'hub_session'));
|
||||
}
|
||||
|
||||
// ── Admin Token (backward compat with soundboard plugin) ──
|
||||
function signAdminTokenCompat(adminPwd: string): string {
|
||||
const payload = { iat: Date.now(), exp: Date.now() + ADMIN_MAX_AGE * 1000 };
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
// ── Discord OAuth2 ──
|
||||
function getRedirectUri(): string {
|
||||
const publicUrl = process.env.PUBLIC_URL ?? '';
|
||||
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/discord/callback`;
|
||||
return `http://localhost:${process.env.PORT ?? 8080}/api/auth/discord/callback`;
|
||||
}
|
||||
|
||||
export function isDiscordConfigured(): boolean {
|
||||
return !!(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
function getDiscordAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: DISCORD_CLIENT_ID,
|
||||
redirect_uri: getRedirectUri(),
|
||||
response_type: 'code',
|
||||
scope: 'identify',
|
||||
state,
|
||||
});
|
||||
return `${DISCORD_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function exchangeDiscordCode(code: string): Promise<string> {
|
||||
const res = await fetch(DISCORD_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: DISCORD_CLIENT_ID,
|
||||
client_secret: DISCORD_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: getRedirectUri(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Discord token exchange failed (${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json() as { access_token: string };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
|
||||
const res = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Discord user fetch failed (${res.status}): ${text}`);
|
||||
}
|
||||
return await res.json() as DiscordUser;
|
||||
}
|
||||
|
||||
// ── Steam OpenID 2.0 ──
|
||||
export function isSteamConfigured(): boolean {
|
||||
return !!STEAM_API_KEY;
|
||||
}
|
||||
|
||||
function getSteamReturnUrl(req: express.Request): string {
|
||||
const publicUrl = process.env.PUBLIC_URL ?? '';
|
||||
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/steam/callback`;
|
||||
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
||||
return `${proto}://${host}/api/auth/steam/callback`;
|
||||
}
|
||||
|
||||
function getSteamRealm(req: express.Request): string {
|
||||
const publicUrl = process.env.PUBLIC_URL ?? '';
|
||||
if (publicUrl) return publicUrl.replace(/\/$/, '');
|
||||
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
async function verifySteamOpenId(query: Record<string, string>): Promise<string | null> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
params.set('openid.mode', 'check_authentication');
|
||||
|
||||
const resp = await fetch(`https://steamcommunity.com/openid/login?${params.toString()}`);
|
||||
const text = await resp.text();
|
||||
if (!text.includes('is_valid:true')) return null;
|
||||
|
||||
const claimedId = String(query['openid.claimed_id'] || '');
|
||||
const match = claimedId.match(/\/id\/(\d+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string }> {
|
||||
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`Steam API error: ${resp.status}`);
|
||||
const json = await resp.json() as any;
|
||||
const player = json?.response?.players?.[0];
|
||||
return {
|
||||
personaName: player?.personaname || steamId,
|
||||
avatarUrl: player?.avatarfull || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Register Routes ──
|
||||
export function registerAuthRoutes(app: express.Application, adminPwd: string): void {
|
||||
|
||||
// Available providers
|
||||
app.get('/api/auth/providers', (_req, res) => {
|
||||
res.json({
|
||||
discord: isDiscordConfigured(),
|
||||
steam: isSteamConfigured(),
|
||||
admin: !!adminPwd,
|
||||
});
|
||||
});
|
||||
|
||||
// Current session
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
res.json({ authenticated: false });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
authenticated: true,
|
||||
provider: session.provider,
|
||||
discordId: session.discordId ?? null,
|
||||
steamId: session.steamId ?? null,
|
||||
username: session.username ?? null,
|
||||
avatar: session.avatar ?? null,
|
||||
globalName: session.globalName ?? null,
|
||||
isAdmin: session.provider === 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
// Discord OAuth2 — start
|
||||
app.get('/api/auth/discord', (_req, res) => {
|
||||
if (!isDiscordConfigured()) {
|
||||
res.status(503).json({ error: 'Discord OAuth nicht konfiguriert (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET fehlen)' });
|
||||
return;
|
||||
}
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
console.log(`[Auth] Discord OAuth2 redirect → ${getRedirectUri()}`);
|
||||
res.redirect(getDiscordAuthUrl(state));
|
||||
});
|
||||
|
||||
// Discord OAuth2 — callback
|
||||
app.get('/api/auth/discord/callback', async (req, res) => {
|
||||
const code = req.query.code as string | undefined;
|
||||
if (!code) {
|
||||
res.status(400).send('Kein Authorization-Code erhalten.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const accessToken = await exchangeDiscordCode(code);
|
||||
const user = await fetchDiscordUser(accessToken);
|
||||
|
||||
const avatarUrl = user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
|
||||
: null;
|
||||
|
||||
const session: UserSession = {
|
||||
provider: 'discord',
|
||||
discordId: user.id,
|
||||
username: user.username,
|
||||
avatar: avatarUrl,
|
||||
globalName: user.global_name,
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + SESSION_MAX_AGE * 1000,
|
||||
};
|
||||
|
||||
const token = signSession(session);
|
||||
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
|
||||
console.log(`[Auth] Discord login: ${user.username} (${user.id})`);
|
||||
res.redirect('/');
|
||||
} catch (e) {
|
||||
console.error('[Auth] Discord callback error:', e);
|
||||
res.status(500).send('Discord Login fehlgeschlagen. Bitte erneut versuchen.');
|
||||
}
|
||||
});
|
||||
|
||||
// Steam OpenID 2.0 — start
|
||||
app.get('/api/auth/steam', (req, res) => {
|
||||
if (!isSteamConfigured()) {
|
||||
res.status(503).json({ error: 'Steam nicht konfiguriert (STEAM_API_KEY fehlt)' });
|
||||
return;
|
||||
}
|
||||
const realm = getSteamRealm(req);
|
||||
const returnTo = getSteamReturnUrl(req);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnTo,
|
||||
'openid.realm': realm,
|
||||
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
});
|
||||
|
||||
console.log(`[Auth] Steam OpenID redirect → ${returnTo}`);
|
||||
res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`);
|
||||
});
|
||||
|
||||
// Steam OpenID 2.0 — callback
|
||||
app.get('/api/auth/steam/callback', async (req, res) => {
|
||||
try {
|
||||
const steamId = await verifySteamOpenId(req.query as Record<string, string>);
|
||||
if (!steamId) {
|
||||
res.status(403).send('Steam-Verifizierung fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await fetchSteamProfile(steamId);
|
||||
|
||||
const session: UserSession = {
|
||||
provider: 'steam',
|
||||
steamId,
|
||||
username: profile.personaName,
|
||||
avatar: profile.avatarUrl || null,
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + SESSION_MAX_AGE * 1000,
|
||||
};
|
||||
|
||||
const token = signSession(session);
|
||||
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
|
||||
console.log(`[Auth] Steam login: ${profile.personaName} (${steamId})`);
|
||||
res.redirect('/');
|
||||
} catch (e) {
|
||||
console.error('[Auth] Steam callback error:', e);
|
||||
res.status(500).send('Steam Login fehlgeschlagen. Bitte erneut versuchen.');
|
||||
}
|
||||
});
|
||||
|
||||
// Admin login (via unified modal)
|
||||
app.post('/api/auth/admin', (req, res) => {
|
||||
if (!adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
|
||||
const { password } = req.body ?? {};
|
||||
if (!password || password !== adminPwd) {
|
||||
res.status(401).json({ error: 'Falsches Passwort' });
|
||||
return;
|
||||
}
|
||||
const session: UserSession = {
|
||||
provider: 'admin',
|
||||
username: 'Admin',
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + ADMIN_MAX_AGE * 1000,
|
||||
};
|
||||
const hubToken = signSession(session);
|
||||
const adminToken = signAdminTokenCompat(adminPwd);
|
||||
// Set hub_session AND legacy admin cookie (soundboard plugin reads 'admin' cookie)
|
||||
res.setHeader('Set-Cookie', [
|
||||
`hub_session=${encodeURIComponent(hubToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
|
||||
`admin=${encodeURIComponent(adminToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
|
||||
]);
|
||||
console.log('[Auth] Admin login');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Logout (clears all session cookies)
|
||||
app.post('/api/auth/logout', (_req, res) => {
|
||||
res.setHeader('Set-Cookie', [
|
||||
'hub_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
|
||||
'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
|
||||
]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { createClient } from './core/discord.js';
|
|||
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
|
||||
import { loadState, getFullState, getStateDiag } from './core/persistence.js';
|
||||
import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js';
|
||||
import { registerAuthRoutes } from './core/discord-auth.js';
|
||||
import radioPlugin from './plugins/radio/index.js';
|
||||
import soundboardPlugin from './plugins/soundboard/index.js';
|
||||
import lolstatsPlugin from './plugins/lolstats/index.js';
|
||||
|
|
@ -130,6 +131,9 @@ function onClientReady(botName: string, client: Client): void {
|
|||
|
||||
// ── Init ──
|
||||
async function boot(): Promise<void> {
|
||||
// ── Auth routes (before plugins so /api/auth/* is available) ──
|
||||
registerAuthRoutes(app, ADMIN_PWD);
|
||||
|
||||
// ── Register plugins with their bot contexts ──
|
||||
registerPlugin(soundboardPlugin, ctxJukebox);
|
||||
registerPlugin(radioPlugin, ctxRadio);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import nacl from 'tweetnacl';
|
|||
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
|
||||
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
||||
import { sseBroadcast } from '../../core/sse.js';
|
||||
import { getSession, getUserId } from '../../core/discord-auth.js';
|
||||
|
||||
// ── Config (env) ──
|
||||
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
|
||||
|
|
@ -532,7 +533,7 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
|
|||
if (cachedPath) {
|
||||
const pcmBuf = getPcmFromMemory(cachedPath);
|
||||
if (pcmBuf) {
|
||||
resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw });
|
||||
resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: true, inputType: StreamType.Raw });
|
||||
} else {
|
||||
resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw });
|
||||
}
|
||||
|
|
@ -1064,7 +1065,7 @@ const soundboardPlugin: Plugin = {
|
|||
if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol);
|
||||
}
|
||||
persistedState.volumes[guildId] = safeVol;
|
||||
writeState();
|
||||
writeStateDebounced();
|
||||
sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol });
|
||||
res.json({ ok: true, volume: safeVol });
|
||||
});
|
||||
|
|
@ -1244,6 +1245,105 @@ const soundboardPlugin: Plugin = {
|
|||
});
|
||||
});
|
||||
|
||||
// ── User Sound Preferences (Discord / Steam authenticated) ──
|
||||
// Get current user's entrance/exit sounds
|
||||
app.get('/api/soundboard/user/sounds', (req, res) => {
|
||||
const session = getSession(req);
|
||||
const userId = session ? getUserId(session) : null;
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
return;
|
||||
}
|
||||
const entrance = persistedState.entranceSounds?.[userId] ?? null;
|
||||
const exit = persistedState.exitSounds?.[userId] ?? null;
|
||||
res.json({ entrance, exit });
|
||||
});
|
||||
|
||||
// Set entrance sound
|
||||
app.post('/api/soundboard/user/entrance', (req, res) => {
|
||||
const session = getSession(req);
|
||||
const userId = session ? getUserId(session) : null;
|
||||
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
|
||||
const { fileName } = req.body ?? {};
|
||||
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
|
||||
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
|
||||
// Resolve file path (same logic as DM handler)
|
||||
const resolve = (() => {
|
||||
try {
|
||||
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
|
||||
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
|
||||
}
|
||||
return '';
|
||||
} catch { return ''; }
|
||||
})();
|
||||
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
|
||||
persistedState.entranceSounds = persistedState.entranceSounds ?? {};
|
||||
persistedState.entranceSounds[userId] = resolve;
|
||||
writeState();
|
||||
console.log(`[Soundboard] User ${session!.username} (${userId}) set entrance: ${resolve}`);
|
||||
res.json({ ok: true, entrance: resolve });
|
||||
});
|
||||
|
||||
// Set exit sound
|
||||
app.post('/api/soundboard/user/exit', (req, res) => {
|
||||
const session = getSession(req);
|
||||
const userId = session ? getUserId(session) : null;
|
||||
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
|
||||
const { fileName } = req.body ?? {};
|
||||
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
|
||||
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
|
||||
const resolve = (() => {
|
||||
try {
|
||||
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
|
||||
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
|
||||
}
|
||||
return '';
|
||||
} catch { return ''; }
|
||||
})();
|
||||
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
|
||||
persistedState.exitSounds = persistedState.exitSounds ?? {};
|
||||
persistedState.exitSounds[userId] = resolve;
|
||||
writeState();
|
||||
console.log(`[Soundboard] User ${session!.username} (${userId}) set exit: ${resolve}`);
|
||||
res.json({ ok: true, exit: resolve });
|
||||
});
|
||||
|
||||
// Remove entrance sound
|
||||
app.delete('/api/soundboard/user/entrance', (req, res) => {
|
||||
const session = getSession(req);
|
||||
const userId = session ? getUserId(session) : null;
|
||||
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
|
||||
if (persistedState.entranceSounds) {
|
||||
delete persistedState.entranceSounds[userId];
|
||||
writeState();
|
||||
}
|
||||
console.log(`[Soundboard] User ${session!.username} (${userId}) removed entrance sound`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Remove exit sound
|
||||
app.delete('/api/soundboard/user/exit', (req, res) => {
|
||||
const session = getSession(req);
|
||||
const userId = session ? getUserId(session) : null;
|
||||
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
|
||||
if (persistedState.exitSounds) {
|
||||
delete persistedState.exitSounds[userId];
|
||||
writeState();
|
||||
}
|
||||
console.log(`[Soundboard] User ${session!.username} (${userId}) removed exit sound`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// List available sounds (for user settings dropdown) - no auth required
|
||||
app.get('/api/soundboard/user/available-sounds', (_req, res) => {
|
||||
const allSounds = listAllSounds();
|
||||
res.json(allSounds.map(s => ({ name: s.name, fileName: s.fileName, folder: s.folder, relativePath: s.relativePath })));
|
||||
});
|
||||
|
||||
// ── Health ──
|
||||
app.get('/api/soundboard/health', (_req, res) => {
|
||||
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length });
|
||||
|
|
|
|||
4830
web/dist/assets/index-Be3HasqO.js
vendored
4830
web/dist/assets/index-Be3HasqO.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/index-BrwtipcK.css
vendored
Normal file
1
web/dist/assets/index-BrwtipcK.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4830
web/dist/assets/index-CqHVUt2T.js
vendored
Normal file
4830
web/dist/assets/index-CqHVUt2T.js
vendored
Normal file
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" />
|
||||
<title>Gaming Hub</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
|
||||
<script type="module" crossorigin src="/assets/index-CqHVUt2T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BrwtipcK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
664
web/src/AdminPanel.tsx
Normal file
664
web/src/AdminPanel.tsx
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
TYPES
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
type Sound = {
|
||||
fileName: string;
|
||||
name: string;
|
||||
folder?: string;
|
||||
relativePath?: string;
|
||||
};
|
||||
|
||||
type SoundsResponse = {
|
||||
items: Sound[];
|
||||
total: number;
|
||||
folders: Array<{ key: string; name: string; count: number }>;
|
||||
};
|
||||
|
||||
interface AdminPanelProps {
|
||||
onClose: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
API HELPERS
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
const SB_API = '/api/soundboard';
|
||||
|
||||
async function fetchAllSounds(): Promise<SoundsResponse> {
|
||||
const url = new URL(`${SB_API}/sounds`, window.location.origin);
|
||||
url.searchParams.set('folder', '__all__');
|
||||
url.searchParams.set('fuzzy', '0');
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Sounds');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiAdminDelete(paths: string[]): Promise<void> {
|
||||
const res = await fetch(`${SB_API}/admin/sounds/delete`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ paths }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Loeschen fehlgeschlagen');
|
||||
}
|
||||
|
||||
async function apiAdminRename(from: string, to: string): Promise<string> {
|
||||
const res = await fetch(`${SB_API}/admin/sounds/rename`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ from, to }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Umbenennen fehlgeschlagen');
|
||||
const data = await res.json();
|
||||
return data?.to as string;
|
||||
}
|
||||
|
||||
function apiUploadFile(
|
||||
file: File,
|
||||
onProgress: (pct: number) => void,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const form = new FormData();
|
||||
form.append('files', file);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${SB_API}/upload`);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
resolve(data.files?.[0]?.name ?? file.name);
|
||||
} catch { resolve(file.name); }
|
||||
} else {
|
||||
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
|
||||
catch { reject(new Error(`HTTP ${xhr.status}`)); }
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Netzwerkfehler'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
COMPONENT
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
type AdminTab = 'soundboard' | 'streaming' | 'game-library';
|
||||
|
||||
export default function AdminPanel({ onClose, onLogout }: AdminPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<AdminTab>('soundboard');
|
||||
|
||||
// ── Toast ──
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
||||
const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
|
||||
// ── Escape key ──
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
SOUNDBOARD ADMIN STATE
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
const [sbSounds, setSbSounds] = useState<Sound[]>([]);
|
||||
const [sbLoading, setSbLoading] = useState(false);
|
||||
const [sbQuery, setSbQuery] = useState('');
|
||||
const [sbSelection, setSbSelection] = useState<Record<string, boolean>>({});
|
||||
const [sbRenameTarget, setSbRenameTarget] = useState('');
|
||||
const [sbRenameValue, setSbRenameValue] = useState('');
|
||||
const [sbUploadProgress, setSbUploadProgress] = useState<number | null>(null);
|
||||
|
||||
const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []);
|
||||
|
||||
const loadSbSounds = useCallback(async () => {
|
||||
setSbLoading(true);
|
||||
try {
|
||||
const d = await fetchAllSounds();
|
||||
setSbSounds(d.items || []);
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Sounds konnten nicht geladen werden', 'error');
|
||||
} finally {
|
||||
setSbLoading(false);
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
// Load on first tab switch
|
||||
const [sbLoaded, setSbLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (activeTab === 'soundboard' && !sbLoaded) {
|
||||
setSbLoaded(true);
|
||||
void loadSbSounds();
|
||||
}
|
||||
}, [activeTab, sbLoaded, loadSbSounds]);
|
||||
|
||||
const sbFiltered = useMemo(() => {
|
||||
const q = sbQuery.trim().toLowerCase();
|
||||
if (!q) return sbSounds;
|
||||
return sbSounds.filter(s => {
|
||||
const key = soundKey(s).toLowerCase();
|
||||
return s.name.toLowerCase().includes(q)
|
||||
|| (s.folder || '').toLowerCase().includes(q)
|
||||
|| key.includes(q);
|
||||
});
|
||||
}, [sbQuery, sbSounds, soundKey]);
|
||||
|
||||
const sbSelectedPaths = useMemo(() =>
|
||||
Object.keys(sbSelection).filter(k => sbSelection[k]),
|
||||
[sbSelection]);
|
||||
|
||||
const sbSelectedVisibleCount = useMemo(() =>
|
||||
sbFiltered.filter(s => !!sbSelection[soundKey(s)]).length,
|
||||
[sbFiltered, sbSelection, soundKey]);
|
||||
|
||||
const sbAllVisibleSelected = sbFiltered.length > 0 && sbSelectedVisibleCount === sbFiltered.length;
|
||||
|
||||
function sbToggleSelection(path: string) {
|
||||
setSbSelection(prev => ({ ...prev, [path]: !prev[path] }));
|
||||
}
|
||||
|
||||
function sbStartRename(sound: Sound) {
|
||||
setSbRenameTarget(soundKey(sound));
|
||||
setSbRenameValue(sound.name);
|
||||
}
|
||||
|
||||
function sbCancelRename() {
|
||||
setSbRenameTarget('');
|
||||
setSbRenameValue('');
|
||||
}
|
||||
|
||||
async function sbSubmitRename() {
|
||||
if (!sbRenameTarget) return;
|
||||
const baseName = sbRenameValue.trim().replace(/\.(mp3|wav)$/i, '');
|
||||
if (!baseName) {
|
||||
notify('Bitte einen gueltigen Namen eingeben', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiAdminRename(sbRenameTarget, baseName);
|
||||
notify('Sound umbenannt');
|
||||
sbCancelRename();
|
||||
await loadSbSounds();
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Umbenennen fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function sbDeletePaths(paths: string[]) {
|
||||
if (paths.length === 0) return;
|
||||
try {
|
||||
await apiAdminDelete(paths);
|
||||
notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`);
|
||||
setSbSelection({});
|
||||
sbCancelRename();
|
||||
await loadSbSounds();
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function sbUpload(file: File) {
|
||||
setSbUploadProgress(0);
|
||||
try {
|
||||
await apiUploadFile(file, pct => setSbUploadProgress(pct));
|
||||
notify(`"${file.name}" hochgeladen`);
|
||||
await loadSbSounds();
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Upload fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
setSbUploadProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
STREAMING ADMIN STATE
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
const [stAvailableChannels, setStAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]);
|
||||
const [stNotifyConfig, setStNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]);
|
||||
const [stConfigLoading, setStConfigLoading] = useState(false);
|
||||
const [stConfigSaving, setStConfigSaving] = useState(false);
|
||||
const [stNotifyStatus, setStNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null });
|
||||
|
||||
const loadStreamingConfig = useCallback(async () => {
|
||||
setStConfigLoading(true);
|
||||
try {
|
||||
const [statusResp, chResp, cfgResp] = await Promise.all([
|
||||
fetch('/api/notifications/status'),
|
||||
fetch('/api/notifications/channels', { credentials: 'include' }),
|
||||
fetch('/api/notifications/config', { credentials: 'include' }),
|
||||
]);
|
||||
if (statusResp.ok) {
|
||||
const d = await statusResp.json();
|
||||
setStNotifyStatus(d);
|
||||
}
|
||||
if (chResp.ok) {
|
||||
const chData = await chResp.json();
|
||||
setStAvailableChannels(chData.channels || []);
|
||||
}
|
||||
if (cfgResp.ok) {
|
||||
const cfgData = await cfgResp.json();
|
||||
setStNotifyConfig(cfgData.channels || []);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setStConfigLoading(false); }
|
||||
}, []);
|
||||
|
||||
const [stLoaded, setStLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (activeTab === 'streaming' && !stLoaded) {
|
||||
setStLoaded(true);
|
||||
void loadStreamingConfig();
|
||||
}
|
||||
}, [activeTab, stLoaded, loadStreamingConfig]);
|
||||
|
||||
const stToggleEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
|
||||
setStNotifyConfig(prev => {
|
||||
const existing = prev.find(c => c.channelId === channelId);
|
||||
if (existing) {
|
||||
const hasEvent = existing.events.includes(event);
|
||||
const newEvents = hasEvent
|
||||
? existing.events.filter(e => e !== event)
|
||||
: [...existing.events, event];
|
||||
if (newEvents.length === 0) {
|
||||
return prev.filter(c => c.channelId !== channelId);
|
||||
}
|
||||
return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c);
|
||||
} else {
|
||||
return [...prev, { channelId, channelName, guildId, guildName, events: [event] }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stIsEnabled = useCallback((channelId: string, event: string): boolean => {
|
||||
const ch = stNotifyConfig.find(c => c.channelId === channelId);
|
||||
return ch?.events.includes(event) ?? false;
|
||||
}, [stNotifyConfig]);
|
||||
|
||||
const stSaveConfig = useCallback(async () => {
|
||||
setStConfigSaving(true);
|
||||
try {
|
||||
await fetch('/api/notifications/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channels: stNotifyConfig }),
|
||||
credentials: 'include',
|
||||
});
|
||||
notify('Konfiguration gespeichert');
|
||||
} catch {
|
||||
notify('Speichern fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
setStConfigSaving(false);
|
||||
}
|
||||
}, [stNotifyConfig, notify]);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
GAME LIBRARY ADMIN STATE
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
const [glProfiles, setGlProfiles] = useState<any[]>([]);
|
||||
const [glLoading, setGlLoading] = useState(false);
|
||||
|
||||
const loadGlProfiles = useCallback(async () => {
|
||||
setGlLoading(true);
|
||||
try {
|
||||
const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
setGlProfiles(d.profiles || []);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setGlLoading(false); }
|
||||
}, []);
|
||||
|
||||
const [glLoaded, setGlLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (activeTab === 'game-library' && !glLoaded) {
|
||||
setGlLoaded(true);
|
||||
void loadGlProfiles();
|
||||
}
|
||||
}, [activeTab, glLoaded, loadGlProfiles]);
|
||||
|
||||
const glDeleteProfile = useCallback(async (profileId: string, displayName: string) => {
|
||||
if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
notify('Profil geloescht');
|
||||
loadGlProfiles();
|
||||
}
|
||||
} catch {
|
||||
notify('Loeschen fehlgeschlagen', 'error');
|
||||
}
|
||||
}, [loadGlProfiles, notify]);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
TAB CONFIG
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
const tabs: { id: AdminTab; icon: string; label: string }[] = [
|
||||
{ id: 'soundboard', icon: '\uD83C\uDFB5', label: 'Soundboard' },
|
||||
{ id: 'streaming', icon: '\uD83D\uDCFA', label: 'Streaming' },
|
||||
{ id: 'game-library', icon: '\uD83C\uDFAE', label: 'Game Library' },
|
||||
];
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
RENDER
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
return (
|
||||
<div className="ap-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="ap-modal">
|
||||
{/* ── Sidebar ── */}
|
||||
<div className="ap-sidebar">
|
||||
<div className="ap-sidebar-title">{'\u2699\uFE0F'} Admin</div>
|
||||
<nav className="ap-nav">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`ap-nav-item ${activeTab === t.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
<span className="ap-nav-icon">{t.icon}</span>
|
||||
<span className="ap-nav-label">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="ap-logout-btn" onClick={onLogout}>
|
||||
{'\uD83D\uDD12'} Abmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="ap-content">
|
||||
<div className="ap-header">
|
||||
<h2 className="ap-title">
|
||||
{tabs.find(t => t.id === activeTab)?.icon}{' '}
|
||||
{tabs.find(t => t.id === activeTab)?.label}
|
||||
</h2>
|
||||
<button className="ap-close" onClick={onClose}>{'\u2715'}</button>
|
||||
</div>
|
||||
|
||||
<div className="ap-body">
|
||||
{/* ═══════════════════ SOUNDBOARD TAB ═══════════════════ */}
|
||||
{activeTab === 'soundboard' && (
|
||||
<div className="ap-tab-content">
|
||||
<div className="ap-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="ap-search"
|
||||
value={sbQuery}
|
||||
onChange={e => setSbQuery(e.target.value)}
|
||||
placeholder="Nach Name, Ordner oder Pfad filtern..."
|
||||
/>
|
||||
<button
|
||||
className="ap-btn ap-btn-outline"
|
||||
onClick={() => { void loadSbSounds(); }}
|
||||
disabled={sbLoading}
|
||||
>
|
||||
{'\u21BB'} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<label className="ap-upload-zone">
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp3,.wav"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) sbUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{sbUploadProgress !== null ? (
|
||||
<span className="ap-upload-progress">Upload: {sbUploadProgress}%</span>
|
||||
) : (
|
||||
<span className="ap-upload-text">{'\u2B06\uFE0F'} Datei hochladen (MP3 / WAV)</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="ap-bulk-row">
|
||||
<label className="ap-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sbAllVisibleSelected}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
const next = { ...sbSelection };
|
||||
sbFiltered.forEach(s => { next[soundKey(s)] = checked; });
|
||||
setSbSelection(next);
|
||||
}}
|
||||
/>
|
||||
<span>Alle sichtbaren ({sbSelectedVisibleCount}/{sbFiltered.length})</span>
|
||||
</label>
|
||||
<button
|
||||
className="ap-btn ap-btn-danger"
|
||||
disabled={sbSelectedPaths.length === 0}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Wirklich ${sbSelectedPaths.length} Sound(s) loeschen?`)) return;
|
||||
await sbDeletePaths(sbSelectedPaths);
|
||||
}}
|
||||
>
|
||||
{'\uD83D\uDDD1\uFE0F'} Ausgewaehlte loeschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sound list */}
|
||||
<div className="ap-list-wrap">
|
||||
{sbLoading ? (
|
||||
<div className="ap-empty">Lade Sounds...</div>
|
||||
) : sbFiltered.length === 0 ? (
|
||||
<div className="ap-empty">Keine Sounds gefunden.</div>
|
||||
) : (
|
||||
<div className="ap-list">
|
||||
{sbFiltered.map(sound => {
|
||||
const key = soundKey(sound);
|
||||
const editing = sbRenameTarget === key;
|
||||
return (
|
||||
<div className="ap-item" key={key}>
|
||||
<label className="ap-item-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!sbSelection[key]}
|
||||
onChange={() => sbToggleSelection(key)}
|
||||
/>
|
||||
</label>
|
||||
<div className="ap-item-main">
|
||||
<div className="ap-item-name">{sound.name}</div>
|
||||
<div className="ap-item-meta">
|
||||
{sound.folder ? `Ordner: ${sound.folder}` : 'Root'}
|
||||
{' \u00B7 '}
|
||||
{key}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="ap-rename-row">
|
||||
<input
|
||||
className="ap-rename-input"
|
||||
value={sbRenameValue}
|
||||
onChange={e => setSbRenameValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') void sbSubmitRename();
|
||||
if (e.key === 'Escape') sbCancelRename();
|
||||
}}
|
||||
placeholder="Neuer Name..."
|
||||
autoFocus
|
||||
/>
|
||||
<button className="ap-btn ap-btn-primary ap-btn-sm" onClick={() => { void sbSubmitRename(); }}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className="ap-btn ap-btn-outline ap-btn-sm" onClick={sbCancelRename}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
<div className="ap-item-actions">
|
||||
<button className="ap-btn ap-btn-outline ap-btn-sm" onClick={() => sbStartRename(sound)}>
|
||||
Umbenennen
|
||||
</button>
|
||||
<button
|
||||
className="ap-btn ap-btn-danger ap-btn-sm"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Sound "${sound.name}" loeschen?`)) return;
|
||||
await sbDeletePaths([key]);
|
||||
}}
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════ STREAMING TAB ═══════════════════ */}
|
||||
{activeTab === 'streaming' && (
|
||||
<div className="ap-tab-content">
|
||||
<div className="ap-toolbar">
|
||||
<span className="ap-status-badge">
|
||||
<span className={`ap-status-dot ${stNotifyStatus.online ? 'online' : ''}`} />
|
||||
{stNotifyStatus.online
|
||||
? <>Bot online: <b>{stNotifyStatus.botTag}</b></>
|
||||
: <>Bot offline</>}
|
||||
</span>
|
||||
<button
|
||||
className="ap-btn ap-btn-outline"
|
||||
onClick={() => { void loadStreamingConfig(); }}
|
||||
disabled={stConfigLoading}
|
||||
>
|
||||
{'\u21BB'} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stConfigLoading ? (
|
||||
<div className="ap-empty">Lade Kanaele...</div>
|
||||
) : stAvailableChannels.length === 0 ? (
|
||||
<div className="ap-empty">
|
||||
{stNotifyStatus.online
|
||||
? 'Keine Text-Kanaele gefunden. Bot hat moeglicherweise keinen Zugriff.'
|
||||
: 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="ap-hint">
|
||||
Waehle die Kanaele, in die Benachrichtigungen gesendet werden sollen:
|
||||
</p>
|
||||
<div className="ap-channel-list">
|
||||
{stAvailableChannels.map(ch => (
|
||||
<div key={ch.channelId} className="ap-channel-row">
|
||||
<div className="ap-channel-info">
|
||||
<span className="ap-channel-name">#{ch.channelName}</span>
|
||||
<span className="ap-channel-guild">{ch.guildName}</span>
|
||||
</div>
|
||||
<div className="ap-channel-toggles">
|
||||
<label className={`ap-toggle ${stIsEnabled(ch.channelId, 'stream_start') ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stIsEnabled(ch.channelId, 'stream_start')}
|
||||
onChange={() => stToggleEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_start')}
|
||||
/>
|
||||
{'\uD83D\uDD34'} Stream Start
|
||||
</label>
|
||||
<label className={`ap-toggle ${stIsEnabled(ch.channelId, 'stream_end') ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stIsEnabled(ch.channelId, 'stream_end')}
|
||||
onChange={() => stToggleEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_end')}
|
||||
/>
|
||||
{'\u23F9\uFE0F'} Stream Ende
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ap-save-row">
|
||||
<button
|
||||
className="ap-btn ap-btn-primary"
|
||||
onClick={stSaveConfig}
|
||||
disabled={stConfigSaving}
|
||||
>
|
||||
{stConfigSaving ? 'Speichern...' : '\uD83D\uDCBE Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════ GAME LIBRARY TAB ═══════════════════ */}
|
||||
{activeTab === 'game-library' && (
|
||||
<div className="ap-tab-content">
|
||||
<div className="ap-toolbar">
|
||||
<span className="ap-status-badge">
|
||||
<span className="ap-status-dot online" />
|
||||
Eingeloggt als Admin
|
||||
</span>
|
||||
<button
|
||||
className="ap-btn ap-btn-outline"
|
||||
onClick={() => { void loadGlProfiles(); }}
|
||||
disabled={glLoading}
|
||||
>
|
||||
{'\u21BB'} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{glLoading ? (
|
||||
<div className="ap-empty">Lade Profile...</div>
|
||||
) : glProfiles.length === 0 ? (
|
||||
<div className="ap-empty">Keine Profile vorhanden.</div>
|
||||
) : (
|
||||
<div className="ap-profile-list">
|
||||
{glProfiles.map((p: any) => (
|
||||
<div key={p.id} className="ap-profile-row">
|
||||
<img className="ap-profile-avatar" src={p.avatarUrl} alt={p.displayName} />
|
||||
<div className="ap-profile-info">
|
||||
<span className="ap-profile-name">{p.displayName}</span>
|
||||
<span className="ap-profile-details">
|
||||
{p.steamName && <span className="ap-platform-badge steam">Steam: {p.steamGames}</span>}
|
||||
{p.gogName && <span className="ap-platform-badge gog">GOG: {p.gogGames}</span>}
|
||||
<span className="ap-profile-total">{p.totalGames} Spiele</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="ap-btn ap-btn-danger ap-btn-sm"
|
||||
onClick={() => glDeleteProfile(p.id, p.displayName)}
|
||||
>
|
||||
{'\uD83D\uDDD1\uFE0F'} Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Toast ── */}
|
||||
{toast && (
|
||||
<div className={`ap-toast ${toast.type}`}>
|
||||
{toast.type === 'error' ? '\u274C' : '\u2705'} {toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
web/src/App.tsx
157
web/src/App.tsx
|
|
@ -5,6 +5,9 @@ import LolstatsTab from './plugins/lolstats/LolstatsTab';
|
|||
import StreamingTab from './plugins/streaming/StreamingTab';
|
||||
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
|
||||
import GameLibraryTab from './plugins/game-library/GameLibraryTab';
|
||||
import AdminPanel from './AdminPanel';
|
||||
import LoginModal from './LoginModal';
|
||||
import UserSettings from './UserSettings';
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
|
|
@ -12,8 +15,25 @@ interface PluginInfo {
|
|||
description: string;
|
||||
}
|
||||
|
||||
interface AuthUser {
|
||||
authenticated: boolean;
|
||||
provider?: 'discord' | 'steam' | 'admin';
|
||||
discordId?: string;
|
||||
steamId?: string;
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
globalName?: string | null;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AuthProviders {
|
||||
discord: boolean;
|
||||
steam: boolean;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
// Plugin tab components
|
||||
const tabComponents: Record<string, React.FC<{ data: any }>> = {
|
||||
const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
|
||||
radio: RadioTab,
|
||||
soundboard: SoundboardTab,
|
||||
lolstats: LolstatsTab,
|
||||
|
|
@ -22,7 +42,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
|
|||
'game-library': GameLibraryTab,
|
||||
};
|
||||
|
||||
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
|
||||
export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) {
|
||||
tabComponents[pluginName] = component;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +60,19 @@ export default function App() {
|
|||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [pluginData, setPluginData] = useState<Record<string, any>>({});
|
||||
|
||||
// ── Unified Auth State ──
|
||||
const [user, setUser] = useState<AuthUser>({ authenticated: false });
|
||||
const [providers, setProviders] = useState<AuthProviders>({ discord: false, steam: false, admin: false });
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showUserSettings, setShowUserSettings] = useState(false);
|
||||
const [showAdminPanel, setShowAdminPanel] = useState(false);
|
||||
|
||||
// Derived state
|
||||
const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true);
|
||||
const isDiscordUser = user.authenticated && user.provider === 'discord';
|
||||
const isSteamUser = user.authenticated && user.provider === 'steam';
|
||||
const isRegularUser = isDiscordUser || isSteamUser;
|
||||
|
||||
// Electron auto-update state
|
||||
const isElectron = !!(window as any).electronAPI?.isElectron;
|
||||
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
|
||||
|
|
@ -54,6 +87,56 @@ export default function App() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Check auth status + providers on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then((data: AuthUser) => setUser(data))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/auth/providers')
|
||||
.then(r => r.json())
|
||||
.then((data: AuthProviders) => setProviders(data))
|
||||
.catch(() => {});
|
||||
|
||||
// Also check legacy admin cookie (backward compat)
|
||||
fetch('/api/soundboard/admin/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.authenticated) {
|
||||
setUser(prev => prev.authenticated ? prev : { authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Admin login handler (for LoginModal)
|
||||
async function handleAdminLogin(password: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/admin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
setUser({ authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unified logout
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
setUser({ authenticated: false });
|
||||
setShowUserSettings(false);
|
||||
setShowAdminPanel(false);
|
||||
}
|
||||
|
||||
// Electron auto-update listeners
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
|
|
@ -153,6 +236,17 @@ export default function App() {
|
|||
'game-library': '\u{1F3AE}',
|
||||
};
|
||||
|
||||
// What happens when the user button is clicked
|
||||
function handleUserButtonClick() {
|
||||
if (!user.authenticated) {
|
||||
setShowLoginModal(true);
|
||||
} else if (isAdmin) {
|
||||
setShowAdminPanel(true);
|
||||
} else if (isRegularUser) {
|
||||
setShowUserSettings(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hub-app">
|
||||
<header className="hub-header">
|
||||
|
|
@ -188,6 +282,34 @@ export default function App() {
|
|||
<span className="hub-download-label">Desktop App</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Unified Login / User button */}
|
||||
<button
|
||||
className={`hub-user-btn ${user.authenticated ? 'logged-in' : ''} ${isAdmin ? 'admin' : ''}`}
|
||||
onClick={handleUserButtonClick}
|
||||
onContextMenu={e => {
|
||||
if (user.authenticated) { e.preventDefault(); handleLogout(); }
|
||||
}}
|
||||
title={
|
||||
user.authenticated
|
||||
? `${user.globalName || user.username || 'Admin'} (Rechtsklick = Abmelden)`
|
||||
: 'Anmelden'
|
||||
}
|
||||
>
|
||||
{user.authenticated ? (
|
||||
isRegularUser && user.avatar ? (
|
||||
<img src={user.avatar} alt="" className="hub-user-avatar" />
|
||||
) : (
|
||||
<span className="hub-user-icon">{isAdmin ? '\uD83D\uDD27' : '\uD83D\uDC64'}</span>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="hub-user-icon">{'\uD83D\uDD12'}</span>
|
||||
<span className="hub-user-label">Login</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="hub-refresh-btn"
|
||||
onClick={() => window.location.reload()}
|
||||
|
|
@ -307,6 +429,35 @@ export default function App() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Modal */}
|
||||
{showLoginModal && (
|
||||
<LoginModal
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
onAdminLogin={handleAdminLogin}
|
||||
providers={providers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User Settings (Discord + Steam users) */}
|
||||
{showUserSettings && isRegularUser && (
|
||||
<UserSettings
|
||||
user={{
|
||||
id: user.discordId || user.steamId || '',
|
||||
provider: user.provider as 'discord' | 'steam',
|
||||
username: user.username ?? '',
|
||||
avatar: user.avatar ?? null,
|
||||
globalName: user.globalName ?? null,
|
||||
}}
|
||||
onClose={() => setShowUserSettings(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Admin Panel */}
|
||||
{showAdminPanel && isAdmin && (
|
||||
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleLogout(); setShowAdminPanel(false); }} />
|
||||
)}
|
||||
|
||||
<main className="hub-content">
|
||||
{plugins.length === 0 ? (
|
||||
<div className="hub-empty">
|
||||
|
|
@ -330,7 +481,7 @@ export default function App() {
|
|||
: { display: 'none' }
|
||||
}
|
||||
>
|
||||
<Comp data={pluginData[p.name] || {}} />
|
||||
<Comp data={pluginData[p.name] || {}} isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
125
web/src/LoginModal.tsx
Normal file
125
web/src/LoginModal.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LoginModalProps {
|
||||
onClose: () => void;
|
||||
onAdminLogin: (password: string) => Promise<boolean>;
|
||||
providers: { discord: boolean; steam: boolean; admin: boolean };
|
||||
}
|
||||
|
||||
export default function LoginModal({ onClose, onAdminLogin, providers }: LoginModalProps) {
|
||||
const [showAdminForm, setShowAdminForm] = useState(false);
|
||||
const [adminPwd, setAdminPwd] = useState('');
|
||||
const [adminError, setAdminError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showAdminForm) setShowAdminForm(false);
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose, showAdminForm]);
|
||||
|
||||
async function handleAdminSubmit() {
|
||||
if (!adminPwd.trim()) return;
|
||||
setLoading(true);
|
||||
setAdminError('');
|
||||
const ok = await onAdminLogin(adminPwd);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
setAdminPwd('');
|
||||
onClose();
|
||||
} else {
|
||||
setAdminError('Falsches Passwort');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hub-login-overlay" onClick={onClose}>
|
||||
<div className="hub-login-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="hub-login-modal-header">
|
||||
<span>{'\uD83D\uDD10'} Anmelden</span>
|
||||
<button className="hub-login-modal-close" onClick={onClose}>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showAdminForm ? (
|
||||
<div className="hub-login-modal-body">
|
||||
<p className="hub-login-subtitle">Melde dich an, um deine Einstellungen zu verwalten.</p>
|
||||
|
||||
<div className="hub-login-providers">
|
||||
{/* Discord */}
|
||||
{providers.discord && (
|
||||
<a href="/api/auth/discord" className="hub-login-provider-btn discord">
|
||||
<svg className="hub-login-provider-icon" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<span>Mit Discord anmelden</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Steam */}
|
||||
{providers.steam && (
|
||||
<a href="/api/auth/steam" className="hub-login-provider-btn steam">
|
||||
<svg className="hub-login-provider-icon" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 12.001-5.373 12.001-12S18.606 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303a3.015 3.015 0 0 0-3.016-3.016 3.015 3.015 0 0 0-3.016 3.016 3.015 3.015 0 0 0 3.016 3.016 3.015 3.015 0 0 0 3.016-3.016zm-5.273-.005c0-1.248 1.013-2.26 2.26-2.26 1.246 0 2.26 1.013 2.26 2.26 0 1.247-1.014 2.26-2.26 2.26-1.248 0-2.26-1.013-2.26-2.26z" />
|
||||
</svg>
|
||||
<span>Mit Steam anmelden</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Admin */}
|
||||
{providers.admin && (
|
||||
<button
|
||||
className="hub-login-provider-btn admin"
|
||||
onClick={() => setShowAdminForm(true)}
|
||||
>
|
||||
<span className="hub-login-provider-icon-emoji">{'\uD83D\uDD27'}</span>
|
||||
<span>Admin Login</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!providers.discord && (
|
||||
<p className="hub-login-hint">
|
||||
{'\u2139\uFE0F'} Discord Login ist nicht konfiguriert. Der Server braucht DISCORD_CLIENT_ID und DISCORD_CLIENT_SECRET.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hub-login-modal-body">
|
||||
<button className="hub-login-back" onClick={() => { setShowAdminForm(false); setAdminError(''); }}>
|
||||
{'\u2190'} Zurück
|
||||
</button>
|
||||
<div className="hub-login-admin-form">
|
||||
<label className="hub-login-admin-label">{'\uD83D\uDD27'} Admin-Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
className="hub-login-admin-input"
|
||||
placeholder="Passwort eingeben..."
|
||||
value={adminPwd}
|
||||
onChange={e => setAdminPwd(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdminSubmit()}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
{adminError && <p className="hub-login-admin-error">{adminError}</p>}
|
||||
<button
|
||||
className="hub-login-admin-submit"
|
||||
onClick={handleAdminSubmit}
|
||||
disabled={loading || !adminPwd.trim()}
|
||||
>
|
||||
{loading ? 'Prüfe...' : 'Einloggen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
web/src/UserSettings.tsx
Normal file
257
web/src/UserSettings.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
provider: 'discord' | 'steam';
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
globalName: string | null;
|
||||
}
|
||||
|
||||
interface SoundOption {
|
||||
name: string;
|
||||
fileName: string;
|
||||
folder: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
interface UserSettingsProps {
|
||||
user: UserInfo;
|
||||
onClose: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function UserSettings({ user, onClose, onLogout }: UserSettingsProps) {
|
||||
const [entranceSound, setEntranceSound] = useState<string | null>(null);
|
||||
const [exitSound, setExitSound] = useState<string | null>(null);
|
||||
const [availableSounds, setAvailableSounds] = useState<SoundOption[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<'entrance' | 'exit' | null>(null);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<'entrance' | 'exit'>('entrance');
|
||||
|
||||
// Fetch current sounds + available sounds
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/soundboard/user/sounds', { credentials: 'include' }).then(r => r.json()),
|
||||
fetch('/api/soundboard/user/available-sounds').then(r => r.json()),
|
||||
])
|
||||
.then(([userSounds, sounds]) => {
|
||||
setEntranceSound(userSounds.entrance ?? null);
|
||||
setExitSound(userSounds.exit ?? null);
|
||||
setAvailableSounds(sounds);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setMessage({ text: 'Fehler beim Laden der Einstellungen', type: 'error' });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
const showMessage = useCallback((text: string, type: 'success' | 'error') => {
|
||||
setMessage({ text, type });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}, []);
|
||||
|
||||
async function setSound(type: 'entrance' | 'exit', fileName: string) {
|
||||
setSaving(type);
|
||||
try {
|
||||
const resp = await fetch(`/api/soundboard/user/${type}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileName }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (type === 'entrance') setEntranceSound(data.entrance);
|
||||
else setExitSound(data.exit);
|
||||
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound gesetzt!`, 'success');
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
||||
showMessage(err.error || 'Fehler', 'error');
|
||||
}
|
||||
} catch {
|
||||
showMessage('Verbindungsfehler', 'error');
|
||||
}
|
||||
setSaving(null);
|
||||
}
|
||||
|
||||
async function removeSound(type: 'entrance' | 'exit') {
|
||||
setSaving(type);
|
||||
try {
|
||||
const resp = await fetch(`/api/soundboard/user/${type}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
if (type === 'entrance') setEntranceSound(null);
|
||||
else setExitSound(null);
|
||||
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound entfernt`, 'success');
|
||||
}
|
||||
} catch {
|
||||
showMessage('Verbindungsfehler', 'error');
|
||||
}
|
||||
setSaving(null);
|
||||
}
|
||||
|
||||
// Group sounds by folder
|
||||
const folders = new Map<string, SoundOption[]>();
|
||||
const q = search.toLowerCase();
|
||||
for (const s of availableSounds) {
|
||||
if (q && !s.name.toLowerCase().includes(q) && !s.fileName.toLowerCase().includes(q)) continue;
|
||||
const key = s.folder || 'Allgemein';
|
||||
if (!folders.has(key)) folders.set(key, []);
|
||||
folders.get(key)!.push(s);
|
||||
}
|
||||
// Sort folders alphabetically, "Allgemein" first
|
||||
const sortedFolders = [...folders.entries()].sort(([a], [b]) => {
|
||||
if (a === 'Allgemein') return -1;
|
||||
if (b === 'Allgemein') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const currentSound = activeSection === 'entrance' ? entranceSound : exitSound;
|
||||
|
||||
return (
|
||||
<div className="hub-usettings-overlay" onClick={onClose}>
|
||||
<div className="hub-usettings-panel" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="hub-usettings-header">
|
||||
<div className="hub-usettings-user">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" className="hub-usettings-avatar" />
|
||||
) : (
|
||||
<div className="hub-usettings-avatar-placeholder">{user.username[0]?.toUpperCase()}</div>
|
||||
)}
|
||||
<div className="hub-usettings-user-info">
|
||||
<span className="hub-usettings-username">{user.globalName || user.username}</span>
|
||||
<span className="hub-usettings-discriminator">
|
||||
{user.provider === 'steam' ? 'Steam' : `@${user.username}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hub-usettings-header-actions">
|
||||
<button className="hub-usettings-logout" onClick={onLogout} title="Abmelden">
|
||||
{'\uD83D\uDEAA'}
|
||||
</button>
|
||||
<button className="hub-usettings-close" onClick={onClose}>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message toast */}
|
||||
{message && (
|
||||
<div className={`hub-usettings-toast ${message.type}`}>
|
||||
{message.type === 'success' ? '\u2705' : '\u274C'} {message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="hub-usettings-loading">
|
||||
<span className="hub-update-spinner" /> Lade Einstellungen...
|
||||
</div>
|
||||
) : (
|
||||
<div className="hub-usettings-content">
|
||||
{/* Section tabs */}
|
||||
<div className="hub-usettings-tabs">
|
||||
<button
|
||||
className={`hub-usettings-tab ${activeSection === 'entrance' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection('entrance')}
|
||||
>
|
||||
{'\uD83D\uDC4B'} Entrance-Sound
|
||||
</button>
|
||||
<button
|
||||
className={`hub-usettings-tab ${activeSection === 'exit' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection('exit')}
|
||||
>
|
||||
{'\uD83D\uDC4E'} Exit-Sound
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current sound display */}
|
||||
<div className="hub-usettings-current">
|
||||
<span className="hub-usettings-current-label">
|
||||
Aktuell: {' '}
|
||||
</span>
|
||||
{currentSound ? (
|
||||
<span className="hub-usettings-current-value">
|
||||
{'\uD83C\uDFB5'} {currentSound}
|
||||
<button
|
||||
className="hub-usettings-remove-btn"
|
||||
onClick={() => removeSound(activeSection)}
|
||||
disabled={saving === activeSection}
|
||||
title="Entfernen"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="hub-usettings-current-none">Kein Sound gesetzt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="hub-usettings-search-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="hub-usettings-search"
|
||||
placeholder="Sounds durchsuchen..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button className="hub-usettings-search-clear" onClick={() => setSearch('')}>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sound list */}
|
||||
<div className="hub-usettings-sounds">
|
||||
{sortedFolders.length === 0 ? (
|
||||
<div className="hub-usettings-empty">
|
||||
{search ? 'Keine Treffer' : 'Keine Sounds verfügbar'}
|
||||
</div>
|
||||
) : (
|
||||
sortedFolders.map(([folder, sounds]) => (
|
||||
<div key={folder} className="hub-usettings-folder">
|
||||
<div className="hub-usettings-folder-name">{'\uD83D\uDCC1'} {folder}</div>
|
||||
<div className="hub-usettings-folder-sounds">
|
||||
{sounds.map(s => {
|
||||
const isSelected = currentSound === s.relativePath || currentSound === s.fileName;
|
||||
return (
|
||||
<button
|
||||
key={s.relativePath}
|
||||
className={`hub-usettings-sound-btn ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => setSound(activeSection, s.fileName)}
|
||||
disabled={saving === activeSection}
|
||||
title={s.relativePath}
|
||||
>
|
||||
<span className="hub-usettings-sound-icon">
|
||||
{isSelected ? '\u2705' : '\uD83C\uDFB5'}
|
||||
</span>
|
||||
<span className="hub-usettings-sound-name">{s.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ function formatDate(iso: string): string {
|
|||
COMPONENT
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export default function GameLibraryTab({ data }: { data: any }) {
|
||||
export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
|
||||
// ── State ──
|
||||
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
|
||||
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
|
||||
|
|
@ -109,13 +109,9 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// ── Admin state ──
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [adminPwd, setAdminPwd] = useState('');
|
||||
const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
const [adminError, setAdminError] = useState('');
|
||||
// ── Admin (centralized in App.tsx) ──
|
||||
const _isAdmin = isAdminProp ?? false;
|
||||
void _isAdmin;
|
||||
|
||||
// ── SSE data sync ──
|
||||
useEffect(() => {
|
||||
|
|
@ -133,76 +129,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
// ── Admin: check login status on mount ──
|
||||
useEffect(() => {
|
||||
fetch('/api/game-library/admin/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(d => setIsAdmin(d.admin === true))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Admin: login ──
|
||||
const adminLogin = useCallback(async () => {
|
||||
setAdminError('');
|
||||
try {
|
||||
const resp = await fetch('/api/game-library/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: adminPwd }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
setIsAdmin(true);
|
||||
setAdminPwd('');
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
setAdminError(d.error || 'Fehler');
|
||||
}
|
||||
} catch {
|
||||
setAdminError('Verbindung fehlgeschlagen');
|
||||
}
|
||||
}, [adminPwd]);
|
||||
|
||||
// ── Admin: logout ──
|
||||
const adminLogout = useCallback(async () => {
|
||||
await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' });
|
||||
setIsAdmin(false);
|
||||
setShowAdmin(false);
|
||||
}, []);
|
||||
|
||||
// ── Admin: load profiles ──
|
||||
const loadAdminProfiles = useCallback(async () => {
|
||||
setAdminLoading(true);
|
||||
try {
|
||||
const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
setAdminProfiles(d.profiles || []);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setAdminLoading(false); }
|
||||
}, []);
|
||||
|
||||
// ── Admin: open panel ──
|
||||
const openAdmin = useCallback(() => {
|
||||
setShowAdmin(true);
|
||||
if (isAdmin) loadAdminProfiles();
|
||||
}, [isAdmin, loadAdminProfiles]);
|
||||
|
||||
// ── Admin: delete profile ──
|
||||
const adminDeleteProfile = useCallback(async (profileId: string, displayName: string) => {
|
||||
if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
loadAdminProfiles();
|
||||
fetchProfiles();
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}, [loadAdminProfiles, fetchProfiles]);
|
||||
|
||||
// ── Steam login ──
|
||||
const connectSteam = useCallback(() => {
|
||||
|
|
@ -552,9 +478,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
</button>
|
||||
)}
|
||||
<div className="gl-login-bar-spacer" />
|
||||
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Profile Chips ── */}
|
||||
|
|
@ -981,74 +904,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* ── Admin Panel ── */}
|
||||
{showAdmin && (
|
||||
<div className="gl-dialog-overlay" onClick={() => setShowAdmin(false)}>
|
||||
<div className="gl-admin-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="gl-admin-header">
|
||||
<h3>⚙️ Game Library Admin</h3>
|
||||
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>✕</button>
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="gl-admin-login">
|
||||
<p>Admin-Passwort eingeben:</p>
|
||||
<div className="gl-admin-login-row">
|
||||
<input
|
||||
type="password"
|
||||
className="gl-dialog-input"
|
||||
placeholder="Passwort"
|
||||
value={adminPwd}
|
||||
onChange={e => setAdminPwd(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="gl-admin-login-btn" onClick={adminLogin}>Login</button>
|
||||
</div>
|
||||
{adminError && <p className="gl-dialog-status error">{adminError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gl-admin-content">
|
||||
<div className="gl-admin-toolbar">
|
||||
<span className="gl-admin-status-text">✅ Eingeloggt als Admin</span>
|
||||
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>↻ Aktualisieren</button>
|
||||
<button className="gl-admin-logout-btn" onClick={adminLogout}>Logout</button>
|
||||
</div>
|
||||
|
||||
{adminLoading ? (
|
||||
<div className="gl-loading">Lade Profile...</div>
|
||||
) : adminProfiles.length === 0 ? (
|
||||
<p className="gl-search-results-title">Keine Profile vorhanden.</p>
|
||||
) : (
|
||||
<div className="gl-admin-list">
|
||||
{adminProfiles.map((p: any) => (
|
||||
<div key={p.id} className="gl-admin-item">
|
||||
<img className="gl-admin-item-avatar" src={p.avatarUrl} alt={p.displayName} />
|
||||
<div className="gl-admin-item-info">
|
||||
<span className="gl-admin-item-name">{p.displayName}</span>
|
||||
<span className="gl-admin-item-details">
|
||||
{p.steamName && <span className="gl-platform-badge steam">Steam: {p.steamGames}</span>}
|
||||
{p.gogName && <span className="gl-platform-badge gog">GOG: {p.gogGames}</span>}
|
||||
<span className="gl-admin-item-total">{p.totalGames} Spiele</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="gl-admin-delete-btn"
|
||||
onClick={() => adminDeleteProfile(p.id, p.displayName)}
|
||||
title="Profil loeschen"
|
||||
>
|
||||
🗑️ Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── GOG Code Dialog (browser fallback only) ── */}
|
||||
{gogDialogOpen && (
|
||||
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
cursor: pointer;
|
||||
|
|
@ -234,7 +234,7 @@
|
|||
gap: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 6px 12px 6px 6px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
|
@ -698,7 +698,7 @@
|
|||
}
|
||||
|
||||
.gl-sort-select option {
|
||||
background: #1a1a2e;
|
||||
background: #1a1810;
|
||||
color: #c7d5e0;
|
||||
}
|
||||
|
||||
|
|
@ -717,7 +717,7 @@
|
|||
color: #8899a6;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
|
@ -776,8 +776,8 @@
|
|||
}
|
||||
|
||||
.gl-dialog {
|
||||
background: #2a2a3e;
|
||||
border-radius: 12px;
|
||||
background: #2a2620;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
|
|
@ -800,7 +800,7 @@
|
|||
.gl-dialog-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #1a1a2e;
|
||||
background: #1a1810;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
|
|
@ -841,7 +841,7 @@
|
|||
|
||||
.gl-dialog-cancel {
|
||||
padding: 8px 18px;
|
||||
background: #3a3a4e;
|
||||
background: #322d26;
|
||||
color: #ccc;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
|
@ -850,7 +850,7 @@
|
|||
}
|
||||
|
||||
.gl-dialog-cancel:hover {
|
||||
background: #4a4a5e;
|
||||
background: #3a352d;
|
||||
}
|
||||
|
||||
.gl-dialog-submit {
|
||||
|
|
@ -896,8 +896,8 @@
|
|||
}
|
||||
|
||||
.gl-admin-panel {
|
||||
background: #2a2a3e;
|
||||
border-radius: 12px;
|
||||
background: #2a2620;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
max-width: 600px;
|
||||
width: 92%;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 16px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
|
|
@ -94,13 +94,13 @@
|
|||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.lol-profile-icon {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: 12px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--bg-tertiary);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@
|
|||
.lol-ranked-card {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 4px solid var(--bg-tertiary);
|
||||
}
|
||||
|
|
@ -517,7 +517,7 @@
|
|||
.lol-tier-mode-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 16px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
|
|
|
|||
|
|
@ -186,25 +186,6 @@ async function apiGetVolume(guildId: string): Promise<number> {
|
|||
return typeof data?.volume === 'number' ? data.volume : 1;
|
||||
}
|
||||
|
||||
async function apiAdminStatus(): Promise<boolean> {
|
||||
const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return !!data?.authenticated;
|
||||
}
|
||||
|
||||
async function apiAdminLogin(password: string): Promise<boolean> {
|
||||
const res = await fetch(`${API_BASE}/admin/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function apiAdminLogout(): Promise<void> {
|
||||
await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' });
|
||||
}
|
||||
|
||||
async function apiAdminDelete(paths: string[]): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
|
|
@ -324,13 +305,14 @@ interface VoiceStats {
|
|||
|
||||
interface SoundboardTabProps {
|
||||
data: any;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
COMPONENT
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||
export default function SoundboardTab({ data, isAdmin: isAdminProp }: SoundboardTabProps) {
|
||||
/* ── Data ── */
|
||||
const [sounds, setSounds] = useState<Sound[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
|
@ -378,15 +360,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
/* ── Admin ── */
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [adminPwd, setAdminPwd] = useState('');
|
||||
const [adminSounds, setAdminSounds] = useState<Sound[]>([]);
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
const [adminQuery, setAdminQuery] = useState('');
|
||||
const [adminSelection, setAdminSelection] = useState<Record<string, boolean>>({});
|
||||
const [renameTarget, setRenameTarget] = useState('');
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const isAdmin = isAdminProp ?? false;
|
||||
|
||||
/* ── Drag & Drop Upload ── */
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
|
@ -521,7 +495,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
|
||||
}
|
||||
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
|
||||
try { setIsAdmin(await apiAdminStatus()); } catch { }
|
||||
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -656,13 +629,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
return () => document.removeEventListener('click', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAdmin && isAdmin) {
|
||||
void loadAdminSounds();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showAdmin, isAdmin]);
|
||||
|
||||
/* ── Actions ── */
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
|
|
@ -821,86 +787,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
setFavs(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
async function loadAdminSounds() {
|
||||
setAdminLoading(true);
|
||||
try {
|
||||
const d = await fetchSounds('', '__all__', undefined, false);
|
||||
setAdminSounds(d.items || []);
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error');
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAdminSelection(path: string) {
|
||||
setAdminSelection(prev => ({ ...prev, [path]: !prev[path] }));
|
||||
}
|
||||
|
||||
function startRename(sound: Sound) {
|
||||
setRenameTarget(soundKey(sound));
|
||||
setRenameValue(sound.name);
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
setRenameTarget('');
|
||||
setRenameValue('');
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
if (!renameTarget) return;
|
||||
const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, '');
|
||||
if (!baseName) {
|
||||
notify('Bitte einen gueltigen Namen eingeben', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiAdminRename(renameTarget, baseName);
|
||||
notify('Sound umbenannt');
|
||||
cancelRename();
|
||||
setRefreshKey(k => k + 1);
|
||||
if (showAdmin) await loadAdminSounds();
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Umbenennen fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdminPaths(paths: string[]) {
|
||||
if (paths.length === 0) return;
|
||||
try {
|
||||
await apiAdminDelete(paths);
|
||||
notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`);
|
||||
setAdminSelection({});
|
||||
cancelRename();
|
||||
setRefreshKey(k => k + 1);
|
||||
if (showAdmin) await loadAdminSounds();
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminLogin() {
|
||||
try {
|
||||
const ok = await apiAdminLogin(adminPwd);
|
||||
if (ok) {
|
||||
setIsAdmin(true);
|
||||
setAdminPwd('');
|
||||
notify('Admin eingeloggt');
|
||||
}
|
||||
else notify('Falsches Passwort', 'error');
|
||||
} catch { notify('Login fehlgeschlagen', 'error'); }
|
||||
}
|
||||
|
||||
async function handleAdminLogout() {
|
||||
try {
|
||||
await apiAdminLogout();
|
||||
setIsAdmin(false);
|
||||
setAdminSelection({});
|
||||
cancelRename();
|
||||
notify('Ausgeloggt');
|
||||
} catch { }
|
||||
}
|
||||
|
||||
/* ── Computed ── */
|
||||
const displaySounds = useMemo(() => {
|
||||
if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
|
||||
|
|
@ -938,26 +824,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
return groups;
|
||||
}, [channels]);
|
||||
|
||||
const adminFilteredSounds = useMemo(() => {
|
||||
const q = adminQuery.trim().toLowerCase();
|
||||
if (!q) return adminSounds;
|
||||
return adminSounds.filter(s => {
|
||||
const key = soundKey(s).toLowerCase();
|
||||
return s.name.toLowerCase().includes(q)
|
||||
|| (s.folder || '').toLowerCase().includes(q)
|
||||
|| key.includes(q);
|
||||
});
|
||||
}, [adminQuery, adminSounds, soundKey]);
|
||||
|
||||
const selectedAdminPaths = useMemo(() =>
|
||||
Object.keys(adminSelection).filter(k => adminSelection[k]),
|
||||
[adminSelection]);
|
||||
|
||||
const selectedVisibleCount = useMemo(() =>
|
||||
adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length,
|
||||
[adminFilteredSounds, adminSelection, soundKey]);
|
||||
|
||||
const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length;
|
||||
const analyticsTop = analytics.mostPlayed.slice(0, 10);
|
||||
const totalSoundsDisplay = analytics.totalSounds || total;
|
||||
|
||||
|
|
@ -1040,13 +906,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`admin-btn-icon ${isAdmin ? 'active' : ''}`}
|
||||
onClick={() => setShowAdmin(true)}
|
||||
title="Admin"
|
||||
>
|
||||
<span className="material-icons">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -1150,7 +1009,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
|
||||
volDebounceRef.current = setTimeout(() => {
|
||||
apiSetVolumeLive(guildId, v).catch(() => {});
|
||||
}, 120);
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
|
||||
|
|
@ -1356,7 +1215,14 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
<div className="ctx-sep" />
|
||||
<div className="ctx-item danger" onClick={async () => {
|
||||
const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName;
|
||||
await deleteAdminPaths([path]);
|
||||
if (!window.confirm(`Sound "${ctxMenu.sound.name}" loeschen?`)) { setCtxMenu(null); return; }
|
||||
try {
|
||||
await apiAdminDelete([path]);
|
||||
notify('Sound geloescht');
|
||||
setRefreshKey(k => k + 1);
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
|
||||
}
|
||||
setCtxMenu(null);
|
||||
}}>
|
||||
<span className="material-icons ctx-icon">delete</span>
|
||||
|
|
@ -1437,159 +1303,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ ADMIN PANEL ═══ */}
|
||||
{showAdmin && (
|
||||
<div className="admin-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAdmin(false); }}>
|
||||
<div className="admin-panel">
|
||||
<h3>
|
||||
Admin
|
||||
<button className="admin-close" onClick={() => setShowAdmin(false)}>
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
|
||||
</button>
|
||||
</h3>
|
||||
{!isAdmin ? (
|
||||
<div>
|
||||
<div className="admin-field">
|
||||
<label>Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPwd}
|
||||
onChange={e => setAdminPwd(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
|
||||
placeholder="Admin-Passwort..."
|
||||
/>
|
||||
</div>
|
||||
<button className="admin-btn-action primary" onClick={handleAdminLogin}>Login</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-shell">
|
||||
<div className="admin-header-row">
|
||||
<p className="admin-status">Eingeloggt als Admin</p>
|
||||
<div className="admin-actions-inline">
|
||||
<button
|
||||
className="admin-btn-action outline"
|
||||
onClick={() => { void loadAdminSounds(); }}
|
||||
disabled={adminLoading}
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-field admin-search-field">
|
||||
<label>Sounds verwalten</label>
|
||||
<input
|
||||
type="text"
|
||||
value={adminQuery}
|
||||
onChange={e => setAdminQuery(e.target.value)}
|
||||
placeholder="Nach Name, Ordner oder Pfad filtern..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-bulk-row">
|
||||
<label className="admin-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
const next = { ...adminSelection };
|
||||
adminFilteredSounds.forEach(s => { next[soundKey(s)] = checked; });
|
||||
setAdminSelection(next);
|
||||
}}
|
||||
/>
|
||||
<span>Alle sichtbaren auswaehlen ({selectedVisibleCount}/{adminFilteredSounds.length})</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="admin-btn-action danger"
|
||||
disabled={selectedAdminPaths.length === 0}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Wirklich ${selectedAdminPaths.length} Sound(s) loeschen?`)) return;
|
||||
await deleteAdminPaths(selectedAdminPaths);
|
||||
}}
|
||||
>
|
||||
Ausgewaehlte loeschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-list-wrap">
|
||||
{adminLoading ? (
|
||||
<div className="admin-empty">Lade Sounds...</div>
|
||||
) : adminFilteredSounds.length === 0 ? (
|
||||
<div className="admin-empty">Keine Sounds gefunden.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{adminFilteredSounds.map(sound => {
|
||||
const key = soundKey(sound);
|
||||
const editing = renameTarget === key;
|
||||
return (
|
||||
<div className="admin-item" key={key}>
|
||||
<label className="admin-item-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!adminSelection[key]}
|
||||
onChange={() => toggleAdminSelection(key)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="admin-item-main">
|
||||
<div className="admin-item-name">{sound.name}</div>
|
||||
<div className="admin-item-meta">
|
||||
{sound.folder ? `Ordner: ${sound.folder}` : 'Root'}
|
||||
{' \u00B7 '}
|
||||
{key}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="admin-rename-row">
|
||||
<input
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') void submitRename();
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
}}
|
||||
placeholder="Neuer Name..."
|
||||
/>
|
||||
<button className="admin-btn-action primary" onClick={() => { void submitRename(); }}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className="admin-btn-action outline" onClick={cancelRename}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
<div className="admin-item-actions">
|
||||
<button className="admin-btn-action outline" onClick={() => startRename(sound)}>
|
||||
Umbenennen
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn-action danger ghost"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Sound "${sound.name}" loeschen?`)) return;
|
||||
await deleteAdminPaths([key]);
|
||||
}}
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Drag & Drop Overlay ── */}
|
||||
{isDragging && (
|
||||
<div className="drop-overlay">
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@
|
|||
Theme Variables — Default (Discord Blurple)
|
||||
──────────────────────────────────────────── */
|
||||
.sb-app {
|
||||
--bg-deep: #1a1b1e;
|
||||
--bg-primary: #1e1f22;
|
||||
--bg-secondary: #2b2d31;
|
||||
--bg-tertiary: #313338;
|
||||
--bg-deep: #1a1810;
|
||||
--bg-primary: #211e17;
|
||||
--bg-secondary: #2a2620;
|
||||
--bg-tertiary: #322d26;
|
||||
--bg-modifier-hover: rgba(79, 84, 92, .16);
|
||||
--bg-modifier-active: rgba(79, 84, 92, .24);
|
||||
--bg-modifier-selected: rgba(79, 84, 92, .32);
|
||||
|
|
@ -183,7 +183,7 @@
|
|||
gap: 8px;
|
||||
padding: 5px 12px 5px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font);
|
||||
|
|
@ -283,7 +283,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: rgba(35, 165, 90, .12);
|
||||
font-size: 12px;
|
||||
color: var(--green);
|
||||
|
|
@ -295,13 +295,12 @@
|
|||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px rgba(35, 165, 90, .6);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { box-shadow: 0 0 6px rgba(35, 165, 90, .5); }
|
||||
50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); }
|
||||
0%, 100% { opacity: .7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.conn-ping {
|
||||
|
|
@ -325,7 +324,7 @@
|
|||
.conn-modal {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
border-radius: 4px;
|
||||
width: 340px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.4);
|
||||
overflow: hidden;
|
||||
|
|
@ -437,7 +436,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
|
|
@ -490,7 +489,7 @@
|
|||
height: 32px;
|
||||
padding: 0 28px 0 32px;
|
||||
border: 1px solid rgba(255, 255, 255, .06);
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font);
|
||||
|
|
@ -541,7 +540,7 @@
|
|||
max-width: 460px;
|
||||
flex: 1;
|
||||
padding: 4px 6px 4px 8px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
|
@ -571,7 +570,7 @@
|
|||
.url-import-btn {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .45);
|
||||
background: rgba(var(--accent-rgb, 88, 101, 242), .12);
|
||||
color: var(--accent);
|
||||
|
|
@ -617,7 +616,7 @@
|
|||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
|
|
@ -656,20 +655,20 @@
|
|||
|
||||
.tb-btn.party:hover {
|
||||
background: var(--yellow);
|
||||
color: #1a1b1e;
|
||||
color: #1a1810;
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
|
||||
.tb-btn.party.active {
|
||||
background: var(--yellow);
|
||||
color: #1a1b1e;
|
||||
color: #1a1810;
|
||||
border-color: var(--yellow);
|
||||
animation: party-btn 600ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes party-btn {
|
||||
from { box-shadow: 0 0 8px rgba(240, 178, 50, .4); }
|
||||
to { box-shadow: 0 0 20px rgba(240, 178, 50, .7); }
|
||||
from { opacity: .85; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.tb-btn.stop {
|
||||
|
|
@ -689,7 +688,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
|
@ -739,7 +738,7 @@
|
|||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
|
@ -759,7 +758,6 @@
|
|||
|
||||
.theme-dot.active {
|
||||
border-color: var(--white);
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, .3);
|
||||
}
|
||||
|
||||
/* ── Analytics Strip ── */
|
||||
|
|
@ -778,7 +776,7 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
|
@ -871,7 +869,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
|
|
@ -946,28 +944,13 @@
|
|||
animation: card-enter 350ms ease-out forwards;
|
||||
}
|
||||
|
||||
.sound-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sound-card:hover {
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-med), 0 0 20px var(--accent-glow);
|
||||
box-shadow: var(--shadow-med);
|
||||
border-color: rgba(88, 101, 242, .2);
|
||||
}
|
||||
|
||||
.sound-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sound-card:active {
|
||||
transform: translateY(0);
|
||||
transition-duration: 50ms;
|
||||
|
|
@ -975,12 +958,7 @@
|
|||
|
||||
.sound-card.playing {
|
||||
border-color: var(--accent);
|
||||
animation: card-enter 350ms ease-out forwards, playing-glow 1.2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes playing-glow {
|
||||
from { box-shadow: 0 0 4px var(--accent-glow); }
|
||||
to { box-shadow: 0 0 16px var(--accent-glow); }
|
||||
animation: card-enter 350ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes card-enter {
|
||||
|
|
@ -1170,7 +1148,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--accent-rgb, 88, 101, 242), .12);
|
||||
border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2);
|
||||
font-size: 12px;
|
||||
|
|
@ -1221,7 +1199,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
|
@ -1301,20 +1279,7 @@
|
|||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(45deg,
|
||||
rgba(255, 0, 0, .04),
|
||||
rgba(0, 255, 0, .04),
|
||||
rgba(0, 0, 255, .04),
|
||||
rgba(255, 255, 0, .04)
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
animation: party-grad 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes party-grad {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
background: rgba(255, 255, 255, .03);
|
||||
}
|
||||
|
||||
@keyframes party-hue {
|
||||
|
|
@ -1386,7 +1351,7 @@
|
|||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
z-index: 100;
|
||||
|
|
@ -1602,7 +1567,7 @@
|
|||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1626,7 +1591,7 @@
|
|||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
border-radius: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
|
|
@ -1852,7 +1817,7 @@
|
|||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 64px 72px;
|
||||
border-radius: 24px;
|
||||
border-radius: 6px;
|
||||
border: 2.5px dashed rgba(var(--accent-rgb), .55);
|
||||
background: rgba(var(--accent-rgb), .07);
|
||||
animation: drop-pulse 2.2s ease-in-out infinite;
|
||||
|
|
@ -1861,11 +1826,9 @@
|
|||
@keyframes drop-pulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(var(--accent-rgb), .45);
|
||||
box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(var(--accent-rgb), .9);
|
||||
box-shadow: 0 0 60px 12px rgba(var(--accent-rgb), .12);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1902,7 +1865,7 @@
|
|||
width: 340px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, .09);
|
||||
border-radius: 14px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, .45);
|
||||
z-index: 200;
|
||||
animation: slide-up 200ms cubic-bezier(.16,1,.3,1);
|
||||
|
|
@ -2048,7 +2011,7 @@
|
|||
width: 420px; max-width: 92vw;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 12px 60px rgba(0, 0, 0, .5);
|
||||
animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,23 +46,22 @@ function formatElapsed(startedAt: string): string {
|
|||
// ── Quality Presets ──
|
||||
|
||||
const QUALITY_PRESETS = [
|
||||
{ label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 },
|
||||
{ label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 },
|
||||
{ label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 },
|
||||
{ label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 },
|
||||
{ label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 },
|
||||
{ label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 },
|
||||
{ label: 'Niedrig \u00B7 4 Mbit \u00B7 60fps', fps: 60, bitrate: 4_000_000 },
|
||||
{ label: 'Mittel \u00B7 8 Mbit \u00B7 60fps', fps: 60, bitrate: 8_000_000 },
|
||||
{ label: 'Hoch \u00B7 14 Mbit \u00B7 60fps', fps: 60, bitrate: 14_000_000 },
|
||||
{ label: 'Ultra \u00B7 25 Mbit \u00B7 60fps', fps: 60, bitrate: 25_000_000 },
|
||||
{ label: 'Max \u00B7 50 Mbit \u00B7 165fps', fps: 165, bitrate: 50_000_000 },
|
||||
] as const;
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export default function StreamingTab({ data }: { data: any }) {
|
||||
export default function StreamingTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
|
||||
// ── State ──
|
||||
const [streams, setStreams] = useState<StreamInfo[]>([]);
|
||||
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
|
||||
const [streamTitle, setStreamTitle] = useState('Screen Share');
|
||||
const [streamPassword, setStreamPassword] = useState('');
|
||||
const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60
|
||||
const [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
|
||||
const [myStreamId, setMyStreamId] = useState<string | null>(null);
|
||||
|
|
@ -73,16 +72,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
// ── Admin / Notification Config ──
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [adminPwd, setAdminPwd] = useState('');
|
||||
const [adminError, setAdminError] = useState('');
|
||||
const [availableChannels, setAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]);
|
||||
const [notifyConfig, setNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [notifyStatus, setNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null });
|
||||
// ── Admin ──
|
||||
const _isAdmin = isAdminProp ?? false;
|
||||
void _isAdmin; // kept for potential future use
|
||||
|
||||
// ── Refs ──
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
|
@ -100,7 +92,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
// Refs that mirror state (avoid stale closures in WS handler)
|
||||
const isBroadcastingRef = useRef(false);
|
||||
const viewingRef = useRef<ViewState | null>(null);
|
||||
const qualityRef = useRef<typeof QUALITY_PRESETS[number]>(QUALITY_PRESETS[2]);
|
||||
const qualityRef = useRef<typeof QUALITY_PRESETS[number]>(QUALITY_PRESETS[1]);
|
||||
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
|
||||
useEffect(() => { viewingRef.current = viewing; }, [viewing]);
|
||||
useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]);
|
||||
|
|
@ -138,17 +130,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
return () => document.removeEventListener('click', handler);
|
||||
}, [openMenu]);
|
||||
|
||||
// Check admin status on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/notifications/admin/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(d => setIsAdmin(d.admin === true))
|
||||
.catch(() => {});
|
||||
fetch('/api/notifications/status')
|
||||
.then(r => r.json())
|
||||
.then(d => setNotifyStatus(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Send via WS ──
|
||||
const wsSend = useCallback((d: Record<string, any>) => {
|
||||
|
|
@ -422,7 +403,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
try {
|
||||
const q = qualityRef.current;
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } },
|
||||
video: { frameRate: { ideal: q.fps } },
|
||||
audio: true,
|
||||
});
|
||||
localStreamRef.current = stream;
|
||||
|
|
@ -610,97 +591,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
setOpenMenu(null);
|
||||
}, [buildStreamLink]);
|
||||
|
||||
// ── Admin functions ──
|
||||
const adminLogin = useCallback(async () => {
|
||||
setAdminError('');
|
||||
try {
|
||||
const resp = await fetch('/api/notifications/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: adminPwd }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
setIsAdmin(true);
|
||||
setAdminPwd('');
|
||||
loadNotifyConfig();
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
setAdminError(d.error || 'Fehler');
|
||||
}
|
||||
} catch {
|
||||
setAdminError('Verbindung fehlgeschlagen');
|
||||
}
|
||||
}, [adminPwd]);
|
||||
|
||||
const adminLogout = useCallback(async () => {
|
||||
await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' });
|
||||
setIsAdmin(false);
|
||||
setShowAdmin(false);
|
||||
}, []);
|
||||
|
||||
const loadNotifyConfig = useCallback(async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const [chResp, cfgResp] = await Promise.all([
|
||||
fetch('/api/notifications/channels', { credentials: 'include' }),
|
||||
fetch('/api/notifications/config', { credentials: 'include' }),
|
||||
]);
|
||||
if (chResp.ok) {
|
||||
const chData = await chResp.json();
|
||||
setAvailableChannels(chData.channels || []);
|
||||
}
|
||||
if (cfgResp.ok) {
|
||||
const cfgData = await cfgResp.json();
|
||||
setNotifyConfig(cfgData.channels || []);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setConfigLoading(false); }
|
||||
}, []);
|
||||
|
||||
const openAdmin = useCallback(() => {
|
||||
setShowAdmin(true);
|
||||
if (isAdmin) loadNotifyConfig();
|
||||
}, [isAdmin, loadNotifyConfig]);
|
||||
|
||||
const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
|
||||
setNotifyConfig(prev => {
|
||||
const existing = prev.find(c => c.channelId === channelId);
|
||||
if (existing) {
|
||||
const hasEvent = existing.events.includes(event);
|
||||
const newEvents = hasEvent
|
||||
? existing.events.filter(e => e !== event)
|
||||
: [...existing.events, event];
|
||||
if (newEvents.length === 0) {
|
||||
return prev.filter(c => c.channelId !== channelId);
|
||||
}
|
||||
return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c);
|
||||
} else {
|
||||
return [...prev, { channelId, channelName, guildId, guildName, events: [event] }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveNotifyConfig = useCallback(async () => {
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const resp = await fetch('/api/notifications/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channels: notifyConfig }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.ok) {
|
||||
// brief visual feedback handled by configSaving state
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setConfigSaving(false); }
|
||||
}, [notifyConfig]);
|
||||
|
||||
const isChannelEventEnabled = useCallback((channelId: string, event: string): boolean => {
|
||||
const ch = notifyConfig.find(c => c.channelId === channelId);
|
||||
return ch?.events.includes(event) ?? false;
|
||||
}, [notifyConfig]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
|
|
@ -754,39 +644,50 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
)}
|
||||
|
||||
<div className="stream-topbar">
|
||||
<input
|
||||
className="stream-input stream-input-name"
|
||||
placeholder="Dein Name"
|
||||
value={userName}
|
||||
onChange={e => setUserName(e.target.value)}
|
||||
disabled={isBroadcasting}
|
||||
/>
|
||||
<input
|
||||
className="stream-input stream-input-title"
|
||||
placeholder="Stream-Titel"
|
||||
value={streamTitle}
|
||||
onChange={e => setStreamTitle(e.target.value)}
|
||||
disabled={isBroadcasting}
|
||||
/>
|
||||
<input
|
||||
className="stream-input stream-input-password"
|
||||
type="password"
|
||||
placeholder="Passwort (optional)"
|
||||
value={streamPassword}
|
||||
onChange={e => setStreamPassword(e.target.value)}
|
||||
disabled={isBroadcasting}
|
||||
/>
|
||||
<select
|
||||
className="stream-select-quality"
|
||||
value={qualityIdx}
|
||||
onChange={e => setQualityIdx(Number(e.target.value))}
|
||||
disabled={isBroadcasting}
|
||||
title="Stream-Qualität"
|
||||
>
|
||||
{QUALITY_PRESETS.map((p, i) => (
|
||||
<option key={p.label} value={i}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="stream-field">
|
||||
<span className="stream-field-label">Name</span>
|
||||
<input
|
||||
className="stream-input stream-input-name"
|
||||
placeholder="Dein Name"
|
||||
value={userName}
|
||||
onChange={e => setUserName(e.target.value)}
|
||||
disabled={isBroadcasting}
|
||||
/>
|
||||
</label>
|
||||
<label className="stream-field stream-field-grow">
|
||||
<span className="stream-field-label">Titel</span>
|
||||
<input
|
||||
className="stream-input stream-input-title"
|
||||
placeholder="Stream-Titel"
|
||||
value={streamTitle}
|
||||
onChange={e => setStreamTitle(e.target.value)}
|
||||
disabled={isBroadcasting}
|
||||
/>
|
||||
</label>
|
||||
<label className="stream-field">
|
||||
<span className="stream-field-label">Passwort</span>
|
||||
<input
|
||||
className="stream-input stream-input-password"
|
||||
type="password"
|
||||
placeholder="optional"
|
||||
value={streamPassword}
|
||||
onChange={e => setStreamPassword(e.target.value)}
|
||||
disabled={isBroadcasting}
|
||||
/>
|
||||
</label>
|
||||
<label className="stream-field">
|
||||
<span className="stream-field-label">Qualit{'\u00E4'}t</span>
|
||||
<select
|
||||
className="stream-select-quality"
|
||||
value={qualityIdx}
|
||||
onChange={e => setQualityIdx(Number(e.target.value))}
|
||||
disabled={isBroadcasting}
|
||||
>
|
||||
{QUALITY_PRESETS.map((p, i) => (
|
||||
<option key={p.label} value={i}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{isBroadcasting ? (
|
||||
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
|
||||
{'\u23F9'} Stream beenden
|
||||
|
|
@ -796,9 +697,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
|
||||
</button>
|
||||
)}
|
||||
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
|
||||
{'\u2699\uFE0F'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{streams.length === 0 && !isBroadcasting ? (
|
||||
|
|
@ -903,100 +801,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Notification Admin Modal ── */}
|
||||
{showAdmin && (
|
||||
<div className="stream-admin-overlay" onClick={() => setShowAdmin(false)}>
|
||||
<div className="stream-admin-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="stream-admin-header">
|
||||
<h3>{'\uD83D\uDD14'} Benachrichtigungen</h3>
|
||||
<button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button>
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="stream-admin-login">
|
||||
<p>Admin-Passwort eingeben:</p>
|
||||
<div className="stream-admin-login-row">
|
||||
<input
|
||||
type="password"
|
||||
className="stream-input"
|
||||
placeholder="Passwort"
|
||||
value={adminPwd}
|
||||
onChange={e => setAdminPwd(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="stream-btn" onClick={adminLogin}>Login</button>
|
||||
</div>
|
||||
{adminError && <p className="stream-admin-error">{adminError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="stream-admin-content">
|
||||
<div className="stream-admin-toolbar">
|
||||
<span className="stream-admin-status">
|
||||
{notifyStatus.online
|
||||
? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></>
|
||||
: <>{'\u26A0\uFE0F'} Bot offline — <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>}
|
||||
</span>
|
||||
<button className="stream-admin-logout" onClick={adminLogout}>Logout</button>
|
||||
</div>
|
||||
|
||||
{configLoading ? (
|
||||
<div className="stream-admin-loading">Lade Kan{'\u00E4'}le...</div>
|
||||
) : availableChannels.length === 0 ? (
|
||||
<div className="stream-admin-empty">
|
||||
{notifyStatus.online
|
||||
? 'Keine Text-Kan\u00E4le gefunden. Bot hat m\u00F6glicherweise keinen Zugriff.'
|
||||
: 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="stream-admin-hint">
|
||||
W{'\u00E4'}hle die Kan{'\u00E4'}le, in die Benachrichtigungen gesendet werden sollen:
|
||||
</p>
|
||||
<div className="stream-admin-channel-list">
|
||||
{availableChannels.map(ch => (
|
||||
<div key={ch.channelId} className="stream-admin-channel">
|
||||
<div className="stream-admin-channel-info">
|
||||
<span className="stream-admin-channel-name">#{ch.channelName}</span>
|
||||
<span className="stream-admin-channel-guild">{ch.guildName}</span>
|
||||
</div>
|
||||
<div className="stream-admin-channel-events">
|
||||
<label className={`stream-admin-event-toggle${isChannelEventEnabled(ch.channelId, 'stream_start') ? ' active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChannelEventEnabled(ch.channelId, 'stream_start')}
|
||||
onChange={() => toggleChannelEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_start')}
|
||||
/>
|
||||
{'\uD83D\uDD34'} Stream Start
|
||||
</label>
|
||||
<label className={`stream-admin-event-toggle${isChannelEventEnabled(ch.channelId, 'stream_end') ? ' active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChannelEventEnabled(ch.channelId, 'stream_end')}
|
||||
onChange={() => toggleChannelEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_end')}
|
||||
/>
|
||||
{'\u23F9\uFE0F'} Stream Ende
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="stream-admin-actions">
|
||||
<button
|
||||
className="stream-btn stream-admin-save"
|
||||
onClick={saveNotifyConfig}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? 'Speichern...' : '\uD83D\uDCBE Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,27 @@
|
|||
/* ── Top Bar ── */
|
||||
.stream-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stream-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stream-field-grow { flex: 1; min-width: 180px; }
|
||||
.stream-field-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-faint);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.stream-input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
|
|
@ -25,11 +40,13 @@
|
|||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.stream-input:focus { border-color: var(--accent); }
|
||||
.stream-input::placeholder { color: var(--text-faint); }
|
||||
.stream-input-name { width: 150px; }
|
||||
.stream-input-title { flex: 1; min-width: 180px; }
|
||||
.stream-input-title { width: 100%; }
|
||||
|
||||
.stream-btn {
|
||||
padding: 10px 20px;
|
||||
|
|
@ -405,11 +422,12 @@
|
|||
|
||||
/* ── Password input in topbar ── */
|
||||
.stream-input-password {
|
||||
width: 140px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.stream-select-quality {
|
||||
width: 120px;
|
||||
width: 210px;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -518,7 +536,7 @@
|
|||
|
||||
.stream-admin-panel {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border-radius: 6px;
|
||||
width: 560px;
|
||||
max-width: 95vw;
|
||||
max-height: 80vh;
|
||||
|
|
@ -631,7 +649,7 @@
|
|||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
|
|
|
|||
|
|
@ -554,9 +554,9 @@
|
|||
}
|
||||
|
||||
.wt-quality-select {
|
||||
background: var(--bg-secondary, #2a2a3e);
|
||||
background: var(--bg-secondary, #2a2620);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #3a3a4e);
|
||||
border: 1px solid var(--border-color, #322d26);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
|
|
@ -862,9 +862,9 @@
|
|||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wt-sync-synced { background: #2ecc71; box-shadow: 0 0 6px rgba(46, 204, 113, 0.5); }
|
||||
.wt-sync-drifting { background: #f1c40f; box-shadow: 0 0 6px rgba(241, 196, 15, 0.5); }
|
||||
.wt-sync-desynced { background: #e74c3c; box-shadow: 0 0 6px rgba(231, 76, 60, 0.5); }
|
||||
.wt-sync-synced { background: #2ecc71; }
|
||||
.wt-sync-drifting { background: #f1c40f; }
|
||||
.wt-sync-desynced { background: #e74c3c; }
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
VOTE BUTTONS
|
||||
|
|
|
|||
1398
web/src/styles.css
1398
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