Compare commits

...

42 commits

Author SHA1 Message Date
Daniel
4e0d691aa1 CI Redesign: Warm-Brown Palette, DM Sans, 4px Radius (basierend auf main)
All checks were successful
Build & Deploy / build (push) Successful in 58s
Build & Deploy / deploy (push) Has been skipped
Build & Deploy / bump-version (push) Has been skipped
- Farben: Discord Blau-Grau -> Warme Braun-Palette (#1a1810, #211e17, #2a2620)
- Fonts: Segoe UI -> DM Sans + DM Mono (Google Fonts)
- Border-Radius: max 6px, Standard 4px (war 8-20px)
- Header-Hoehe: 44px (war 56px)
- Glow-Effekte und dekorative Gradients entfernt
- backdrop-filter: blur nur noch auf Modal-Overlays
- Font-Size: 13px (war 15px)
- Plugin-CSS angepasst (Soundboard, Game-Library, LoLStats, Streaming, Watch-Together)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:15:31 +01:00
Forgejo CI
32918e0a7a v1.8.18 [skip ci] 2026-03-10 22:32:47 +00:00
Daniel
7f0b17291f fix(electron): use proper UTF-8 in screen picker
All checks were successful
Build & Deploy / build (push) Successful in 59s
Build & Deploy / deploy (push) Successful in 4s
Build & Deploy / bump-version (push) Successful in 2s
Replace escaped unicode sequences (\uD83D\uDD0A, \u00e4) with actual
UTF-8 characters so the picker displays 🔊 and ä correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:31:35 +01:00
Forgejo CI
4478ac6a6a v1.8.17 [skip ci] 2026-03-10 22:21:58 +00:00
Daniel
7136aafec6 chore(electron): sync version to 1.8.15
All checks were successful
Build & Deploy / build (push) Successful in 53s
Build & Deploy / deploy (push) Successful in 6s
Build & Deploy / bump-version (push) Successful in 2s
Align Electron app version with server release for consistent
auto-update delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:20:49 +01:00
Forgejo CI
e1e2b9a1d8 v1.8.16 [skip ci] 2026-03-10 22:08:08 +00:00
Daniel
1da6c76017 fix(electron): crash when cancelling screen picker
All checks were successful
Build & Deploy / build (push) Successful in 59s
Build & Deploy / deploy (push) Successful in 3s
Build & Deploy / bump-version (push) Successful in 5s
Calling callback({}) with an empty object caused Electron to throw
"Video was requested, but no video stream was provided". The correct
way to cancel/deny the request is callback() with no arguments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:06:54 +01:00
Forgejo CI
694f4371ce v1.8.15 [skip ci] 2026-03-10 21:40:14 +00:00
Daniel
b8e4139a91 feat(electron): add audio toggle to screen picker
All checks were successful
Build & Deploy / build (push) Successful in 55s
Build & Deploy / deploy (push) Successful in 6s
Build & Deploy / bump-version (push) Successful in 2s
The stream screen picker now shows a toggle switch to enable/disable
system audio capture (loopback). Defaults to on. Previously audio was
always included with no way to disable it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:39:03 +01:00
Forgejo CI
7fe9a16cd8 v1.8.14 [skip ci] 2026-03-10 21:29:44 +00:00
Daniel
24e8a6b3f7 ci: mirror Docker images to daddelolymp registry
All checks were successful
Build & Deploy / build (push) Successful in 1m0s
Build & Deploy / deploy (push) Successful in 3s
Build & Deploy / bump-version (push) Successful in 3s
After building and pushing to adriahub, the CI pipeline now also
tags and pushes images to forgejo.daddelolymp.de as a backup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:28:31 +01:00
Forgejo CI
bf69827dbd v1.8.13 [skip ci] 2026-03-10 21:19:44 +00:00
Daniel
d135aab6dc feat: add Steam OpenID login
All checks were successful
Build & Deploy / build (push) Successful in 47s
Build & Deploy / deploy (push) Successful in 4s
Build & Deploy / bump-version (push) Successful in 2s
- Add Steam OpenID 2.0 authentication routes (login + callback)
- Enable Steam button in LoginModal (was placeholder)
- Unified user ID system: getUserId() supports Discord, Steam, Admin
- Update soundboard user-sound endpoints for Steam users
- UserSettings now works for both Discord and Steam providers
- Steam hover uses brand color #66c0f4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:18:44 +01:00
Forgejo CI
aa998c9b44 v1.8.12 [skip ci] 2026-03-10 21:03:19 +00:00
Daniel
81c73407a0 ci: add Discord OAuth2 env vars to deploy step
All checks were successful
Build & Deploy / build (push) Successful in 42s
Build & Deploy / deploy (push) Successful in 5s
Build & Deploy / bump-version (push) Successful in 3s
Pass DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET secrets
to the container for Discord OAuth2 login support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:02:21 +01:00
Forgejo CI
6224db68b3 v1.8.11 [skip ci] 2026-03-10 19:42:36 +00:00
Daniel
99d69f30ba feat: Discord OAuth Login + User Settings GUI
All checks were successful
Build & Deploy / build (push) Successful in 44s
Build & Deploy / deploy (push) Successful in 5s
Build & Deploy / bump-version (push) Successful in 2s
- Neues unified Login-Modal (Discord, Steam, Admin) ersetzt alten Admin-Login
- Discord OAuth2 Backend (server/src/core/discord-auth.ts)
- User Settings Panel: Entrance/Exit Sounds per Web-GUI konfigurierbar
- API-Endpoints: /api/soundboard/user/{sounds,entrance,exit}
- Session-Management via HMAC-signierte Cookies (hub_session)
- Steam-Button als Platzhalter (bald verfuegbar)
- Backward-kompatibel mit bestehendem Admin-Cookie

Benoetigte neue Env-Vars: DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
Discord Redirect URI: PUBLIC_URL/api/auth/discord/callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:41:31 +01:00
Forgejo CI
a7e8407996 v1.8.10 [skip ci] 2026-03-10 18:27:46 +00:00
5ff0dad282 Fix: use HTTPS domain for registry push [skip ci] 2026-03-10 19:26:34 +01:00
710081fe21 Remove debug workflow
Some checks failed
Build & Deploy / build (push) Failing after 1m4s
Build & Deploy / deploy (push) Has been skipped
Build & Deploy / bump-version (push) Has been skipped
2026-03-10 17:47:09 +01:00
7e1b4e7860 Fix: use git clone instead of actions/checkout (no Node in docker:latest)
Some checks failed
Build & Deploy / deploy (push) Blocked by required conditions
Build & Deploy / bump-version (push) Blocked by required conditions
Build & Deploy / build (push) Has been cancelled
2026-03-10 17:46:30 +01:00
9c483cedea Debug: test runner
Some checks failed
Build & Deploy / build (push) Failing after 3s
Build & Deploy / deploy (push) Has been skipped
Build & Deploy / electron-build (push) Has been skipped
Build & Deploy / bump-version (push) Has been skipped
2026-03-10 17:44:23 +01:00
5796a6d620 Add: Forgejo CI/CD workflow (migrated from GitLab CI)
Some checks failed
Build & Deploy / build (push) Failing after 7s
Build & Deploy / deploy (push) Has been skipped
Build & Deploy / electron-build (push) Has been skipped
Build & Deploy / bump-version (push) Has been skipped
2026-03-10 17:38:06 +01:00
GitLab CI
c24b4c5d9e v1.8.9 [skip ci] 2026-03-09 23:00:49 +00:00
Daniel
7ed6b81584 Fix: Volume-Slider reaktionsschneller (Latenz reduziert)
- Server: writeState() → writeStateDebounced() im Volume-Endpoint
  (kein synchroner Disk-Write bei jedem Slider-Tick mehr)
- Frontend: Debounce von 120ms auf 50ms reduziert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:58:03 +01:00
GitLab CI
25e47fb093 v1.8.8 [skip ci] 2026-03-09 22:43:59 +00:00
Daniel
0bd31d93a8 Fix: Lautstärke während Wiedergabe steuerbar (inlineVolume immer aktiv)
Bei PCM-Memory-Cache wurde inlineVolume nur aktiviert wenn vol != 1,
dadurch fehlte das Volume-Objekt auf der AudioResource und
apiSetVolumeLive konnte die Lautstärke nicht live ändern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:41:11 +01:00
GitLab CI
c0956fbc7e v1.8.7 [skip ci] 2026-03-09 21:45:35 +00:00
Daniel
8951f46536 Admin Panel: Logout-Button in Sidebar hinzugefügt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:42:40 +01:00
GitLab CI
354a9cd977 v1.8.6 [skip ci] 2026-03-09 21:36:12 +00:00
Daniel
3f175ca02c Unified Admin Panel: 3 Plugin-Settings in ein zentrales Modal
- Neues AdminPanel.tsx mit Sidebar-Navigation (Soundboard/Streaming/Game Library)
- Lazy-Loading: Daten werden erst beim Tab-Wechsel geladen
- Admin-Button im Header öffnet jetzt das zentrale Panel (Rechtsklick = Logout)
- Admin-Code aus SoundboardTab, StreamingTab und GameLibraryTab entfernt
- ~500 Zeilen Plugin-Code entfernt, durch ~620 Zeilen zentrales Panel ersetzt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:33:19 +01:00
GitLab CI
4b23d013f9 v1.8.5 [skip ci] 2026-03-09 21:02:15 +00:00
Daniel
e9931d82af Refactor: Zentralisiertes Admin-Login im Top-Menü
- Admin-Login aus 3 Plugins (Soundboard, Streaming, Game Library) entfernt
- Zentraler 🔒/🔓 Button im Header mit Login-Modal
- isAdmin wird als Prop an alle Plugins weitergegeben
- Settings-Buttons (Gear-Icons) nur sichtbar wenn eingeloggt
- Alle Plugins nutzen weiterhin den shared admin-Cookie für Operationen
- Login/Logout-Formulare und Buttons aus Plugin-Panels entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:59:21 +01:00
GitLab CI
bccfee3de2 v1.8.4 [skip ci] 2026-03-09 20:47:59 +00:00
Daniel
b556863f52 Fix: Admin-Button entfernt der auf main nicht existierende State-Variablen referenzierte
Cherry-Pick von 041557c8 hatte versehentlich Admin-Button JSX mit übernommen,
aber adminLoggedIn/setShowAdminModal existieren nur auf nightly (nach b3080fb7).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:44:33 +01:00
Daniel
65a1d6e869 Streaming: Qualitaets-Dropdown schmaler (250px -> 210px)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:38:44 +01:00
Daniel
e54f240523 Streaming: fps bei allen Qualitaetsstufen anzeigen
Labels: Niedrig · 4 Mbit · 60fps bis Max · 50 Mbit · 165fps.
Dropdown auf 250px verbreitert.

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

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

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

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

[skip ci]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:37:36 +01:00
GitLab CI
39e180aad4 v1.8.3 [skip ci] 2026-03-08 23:50:31 +00:00
26 changed files with 8199 additions and 5680 deletions

View 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

View file

@ -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

View file

@ -1 +1 @@
1.8.2
1.8.18

View file

@ -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();
}
});
});

View file

@ -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",

View 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 });
});
}

View file

@ -8,6 +8,7 @@ import { createClient } from './core/discord.js';
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
import { loadState, getFullState, getStateDiag } from './core/persistence.js';
import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js';
import { 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);

View file

@ -17,6 +17,7 @@ import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { 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 });

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
<script type="module" crossorigin src="/assets/index-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
View 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>
);
}

View file

@ -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
View 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
View 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>
);
}

View file

@ -89,7 +89,7 @@ function formatDate(iso: string): string {
COMPONENT
*/
export default function GameLibraryTab({ data }: { data: any }) {
export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { 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">
&#x2699;&#xFE0F;
</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>&#x2699;&#xFE0F; Game Library Admin</h3>
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>&#x2715;</button>
</div>
{!isAdmin ? (
<div className="gl-admin-login">
<p>Admin-Passwort eingeben:</p>
<div className="gl-admin-login-row">
<input
type="password"
className="gl-dialog-input"
placeholder="Passwort"
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
autoFocus
/>
<button className="gl-admin-login-btn" onClick={adminLogin}>Login</button>
</div>
{adminError && <p className="gl-dialog-status error">{adminError}</p>}
</div>
) : (
<div className="gl-admin-content">
<div className="gl-admin-toolbar">
<span className="gl-admin-status-text">&#x2705; Eingeloggt als Admin</span>
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>&#x21bb; Aktualisieren</button>
<button className="gl-admin-logout-btn" onClick={adminLogout}>Logout</button>
</div>
{adminLoading ? (
<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"
>
&#x1F5D1;&#xFE0F; Entfernen
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* ── GOG Code Dialog (browser fallback only) ── */}
{gogDialogOpen && (
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>

View file

@ -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%;

View file

@ -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;

View file

@ -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">

View file

@ -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);
}

View file

@ -46,23 +46,22 @@ function formatElapsed(startedAt: string): string {
// ── Quality Presets ──
const QUALITY_PRESETS = [
{ label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 },
{ label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 },
{ label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 },
{ label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 },
{ label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 },
{ label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 },
{ label: 'Niedrig \u00B7 4 Mbit \u00B7 60fps', fps: 60, bitrate: 4_000_000 },
{ label: 'Mittel \u00B7 8 Mbit \u00B7 60fps', fps: 60, bitrate: 8_000_000 },
{ label: 'Hoch \u00B7 14 Mbit \u00B7 60fps', fps: 60, bitrate: 14_000_000 },
{ label: 'Ultra \u00B7 25 Mbit \u00B7 60fps', fps: 60, bitrate: 25_000_000 },
{ label: 'Max \u00B7 50 Mbit \u00B7 165fps', fps: 165, bitrate: 50_000_000 },
] as const;
// ── Component ──
export default function StreamingTab({ data }: { data: any }) {
export default function StreamingTab({ data, isAdmin: isAdminProp }: { 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>
);
}

View file

@ -9,12 +9,27 @@
/* ── Top Bar ── */
.stream-topbar {
display: flex;
align-items: center;
align-items: flex-end;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.stream-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.stream-field-grow { flex: 1; min-width: 180px; }
.stream-field-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
padding-left: 2px;
}
.stream-input {
padding: 10px 14px;
border: 1px solid var(--bg-tertiary);
@ -25,11 +40,13 @@
outline: none;
transition: border-color var(--transition);
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.stream-input:focus { border-color: var(--accent); }
.stream-input::placeholder { color: var(--text-faint); }
.stream-input-name { width: 150px; }
.stream-input-title { flex: 1; min-width: 180px; }
.stream-input-title { width: 100%; }
.stream-btn {
padding: 10px 20px;
@ -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);

View file

@ -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

File diff suppressed because it is too large Load diff