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
30 changed files with 10070 additions and 9025 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

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

View file

@ -1 +1 @@
1.8.2 1.8.18

View file

@ -134,7 +134,7 @@ function createWindow() {
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } }); const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } });
if (sources.length === 0) { if (sources.length === 0) {
callback({}); callback();
return; return;
} }
@ -155,16 +155,31 @@ h2{font-size:16px;margin-bottom:12px;color:#ccc}
.item:hover{border-color:#7c5cff;transform:scale(1.03)} .item:hover{border-color:#7c5cff;transform:scale(1.03)}
.item img{width:100%;height:120px;object-fit:cover;display:block;background:#111} .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} .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{background:#3a3a4e;color:#e0e0e0;border:none;padding:8px 24px;border-radius:6px;cursor:pointer;font-size:14px}
.cancel-btn:hover{background:#4a4a5e} .cancel-btn:hover{background:#4a4a5e}
</style></head><body> </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="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> <script>
const sources = ${JSON.stringify(sourceData)}; const sources = ${JSON.stringify(sourceData)};
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
const audioToggle = document.getElementById('audioToggle');
sources.forEach(s => { sources.forEach(s => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'item'; div.className = 'item';
@ -176,7 +191,7 @@ sources.forEach(s => {
label.textContent = s.name; label.textContent = s.name;
div.appendChild(label); div.appendChild(label);
div.addEventListener('click', () => { 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); grid.appendChild(div);
}); });
@ -209,22 +224,24 @@ document.getElementById('cancelBtn').addEventListener('click', () => {
let resolved = false; let resolved = false;
const onPickerResult = (_event, selectedId) => { const onPickerResult = (_event, selection) => {
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
ipcMain.removeListener(PICKER_CHANNEL, onPickerResult); ipcMain.removeListener(PICKER_CHANNEL, onPickerResult);
picker.close(); picker.close();
try { fs.unlinkSync(tmpFile); } catch {} try { fs.unlinkSync(tmpFile); } catch {}
if (!selectedId) { if (!selection) {
callback({}); callback();
return; 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); const chosen = sources.find(s => s.id === selectedId);
if (chosen) { if (chosen) {
callback({ video: chosen, audio: 'loopback' }); callback(withAudio ? { video: chosen, audio: 'loopback' } : { video: chosen });
} else { } else {
callback({}); callback();
} }
}; };
@ -235,7 +252,7 @@ document.getElementById('cancelBtn').addEventListener('click', () => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
ipcMain.removeListener(PICKER_CHANNEL, onPickerResult); ipcMain.removeListener(PICKER_CHANNEL, onPickerResult);
callback({}); callback();
} }
}); });
}); });

View file

@ -1,7 +1,7 @@
{ {
"name": "gaming-hub-desktop", "name": "gaming-hub-desktop",
"productName": "Gaming Hub", "productName": "Gaming Hub",
"version": "1.8.0", "version": "1.8.16",
"description": "Gaming Hub Desktop App mit Ad-Blocker", "description": "Gaming Hub Desktop App mit Ad-Blocker",
"author": "Gaming Hub", "author": "Gaming Hub",
"main": "main.js", "main": "main.js",

View file

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

View file

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

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

View file

@ -8,7 +8,7 @@ import { createClient } from './core/discord.js';
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
import { loadState, getFullState, getStateDiag } from './core/persistence.js'; import { loadState, getFullState, getStateDiag } from './core/persistence.js';
import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js';
import { signAdminToken, verifyAdminToken, readCookie, setAdminCookie, clearAdminCookie, COOKIE_NAME } from './core/auth.js'; import { registerAuthRoutes } from './core/discord-auth.js';
import radioPlugin from './plugins/radio/index.js'; import radioPlugin from './plugins/radio/index.js';
import soundboardPlugin from './plugins/soundboard/index.js'; import soundboardPlugin from './plugins/soundboard/index.js';
import lolstatsPlugin from './plugins/lolstats/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js';
@ -94,25 +94,16 @@ app.get('/api/health', (_req, res) => {
}); });
}); });
// ── Admin Auth (centralised) ── // ── Admin Login ──
app.post('/api/admin/login', (req, res) => { app.post('/api/admin/login', (req, res) => {
if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; }
const { password } = req.body ?? {}; const { password } = req.body ?? {};
if (password === ADMIN_PWD) { if (password === ADMIN_PWD) {
const token = signAdminToken(ADMIN_PWD);
setAdminCookie(res, token);
res.json({ ok: true }); res.json({ ok: true });
} else { } else {
res.status(401).json({ error: 'Invalid password' }); res.status(401).json({ error: 'Invalid password' });
} }
}); });
app.post('/api/admin/logout', (_req, res) => {
clearAdminCookie(res);
res.json({ ok: true });
});
app.get('/api/admin/status', (req, res) => {
res.json({ authenticated: verifyAdminToken(ADMIN_PWD, readCookie(req, COOKIE_NAME)) });
});
// ── API: List plugins ── // ── API: List plugins ──
app.get('/api/plugins', (_req, res) => { app.get('/api/plugins', (_req, res) => {
@ -140,6 +131,9 @@ function onClientReady(botName: string, client: Client): void {
// ── Init ── // ── Init ──
async function boot(): Promise<void> { async function boot(): Promise<void> {
// ── Auth routes (before plugins so /api/auth/* is available) ──
registerAuthRoutes(app, ADMIN_PWD);
// ── Register plugins with their bot contexts ── // ── Register plugins with their bot contexts ──
registerPlugin(soundboardPlugin, ctxJukebox); registerPlugin(soundboardPlugin, ctxJukebox);
registerPlugin(radioPlugin, ctxRadio); registerPlugin(radioPlugin, ctxRadio);

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js'; import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; import { getSession, getUserId } from '../../core/discord-auth.js';
// ── Config (env) ── // ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
@ -533,7 +533,7 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
if (cachedPath) { if (cachedPath) {
const pcmBuf = getPcmFromMemory(cachedPath); const pcmBuf = getPcmFromMemory(cachedPath);
if (pcmBuf) { if (pcmBuf) {
resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw }); resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: true, inputType: StreamType.Raw });
} else { } else {
resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw }); resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw });
} }
@ -584,6 +584,33 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
if (relativeKey) incrementPlaysFor(relativeKey); if (relativeKey) incrementPlaysFor(relativeKey);
} }
// ── Admin Auth (JWT-like with HMAC) ──
type AdminPayload = { iat: number; exp: number };
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function signAdminToken(adminPwd: string, payload: AdminPayload): string {
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!token || !adminPwd) return false;
const [body, sig] = token.split('.');
if (!body || !sig) return false;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
function readCookie(req: express.Request, key: string): string | undefined {
const c = req.headers.cookie;
if (!c) return undefined;
for (const part of c.split(';')) { const [k, v] = part.trim().split('='); if (k === key) return decodeURIComponent(v || ''); }
return undefined;
}
// ── Party Mode ── // ── Party Mode ──
function schedulePartyPlayback(guildId: string, channelId: string) { function schedulePartyPlayback(guildId: string, channelId: string) {
@ -749,7 +776,28 @@ const soundboardPlugin: Plugin = {
}, },
registerRoutes(app: express.Application, ctx: PluginContext) { registerRoutes(app: express.Application, ctx: PluginContext) {
const requireAdmin = requireAdminFactory(ctx.adminPwd); const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
next();
};
// ── Admin Auth ──
app.post('/api/soundboard/admin/login', (req, res) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 });
res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
res.json({ ok: true });
});
app.post('/api/soundboard/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
res.json({ ok: true });
});
app.get('/api/soundboard/admin/status', (req, res) => {
res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
});
// ── Sounds ── // ── Sounds ──
app.get('/api/soundboard/sounds', (req, res) => { app.get('/api/soundboard/sounds', (req, res) => {
@ -1017,7 +1065,7 @@ const soundboardPlugin: Plugin = {
if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol); if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol);
} }
persistedState.volumes[guildId] = safeVol; persistedState.volumes[guildId] = safeVol;
writeState(); writeStateDebounced();
sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol }); sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol });
res.json({ ok: true, volume: safeVol }); res.json({ ok: true, volume: safeVol });
}); });
@ -1197,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 ── // ── Health ──
app.get('/api/soundboard/health', (_req, res) => { app.get('/api/soundboard/health', (_req, res) => {
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length }); 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

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

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title> <title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-CG_5yn3u.js"></script> <script type="module" crossorigin src="/assets/index-CqHVUt2T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BStrUazC.css"> <link rel="stylesheet" crossorigin href="/assets/index-BrwtipcK.css">
</head> </head>
<body> <body>
<div id="root"></div> <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 StreamingTab from './plugins/streaming/StreamingTab';
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab'; import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
import GameLibraryTab from './plugins/game-library/GameLibraryTab'; import GameLibraryTab from './plugins/game-library/GameLibraryTab';
import AdminPanel from './AdminPanel';
import LoginModal from './LoginModal';
import UserSettings from './UserSettings';
interface PluginInfo { interface PluginInfo {
name: string; name: string;
@ -12,6 +15,23 @@ interface PluginInfo {
description: string; 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 // Plugin tab components
const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = { const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab, radio: RadioTab,
@ -40,20 +60,18 @@ export default function App() {
const [showVersionModal, setShowVersionModal] = useState(false); const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({}); const [pluginData, setPluginData] = useState<Record<string, any>>({});
// Admin state // ── Unified Auth State ──
const [adminLoggedIn, setAdminLoggedIn] = useState(false); const [user, setUser] = useState<AuthUser>({ authenticated: false });
const [showAdminModal, setShowAdminModal] = useState(false); const [providers, setProviders] = useState<AuthProviders>({ discord: false, steam: false, admin: false });
const [adminPassword, setAdminPassword] = useState(''); const [showLoginModal, setShowLoginModal] = useState(false);
const [adminError, setAdminError] = useState(''); const [showUserSettings, setShowUserSettings] = useState(false);
const [showAdminPanel, setShowAdminPanel] = useState(false);
// Accent theme state // Derived state
const [accentTheme, setAccentTheme] = useState<string>(() => { const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true);
return localStorage.getItem('gaming-hub-accent') || 'ember'; const isDiscordUser = user.authenticated && user.provider === 'discord';
}); const isSteamUser = user.authenticated && user.provider === 'steam';
const isRegularUser = isDiscordUser || isSteamUser;
useEffect(() => {
localStorage.setItem('gaming-hub-accent', accentTheme);
}, [accentTheme]);
// Electron auto-update state // Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron; const isElectron = !!(window as any).electronAPI?.isElectron;
@ -69,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 // Electron auto-update listeners
useEffect(() => { useEffect(() => {
if (!isElectron) return; if (!isElectron) return;
@ -145,60 +213,13 @@ export default function App() {
const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev'; const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev';
// Close modals on Escape // Close version modal on Escape
useEffect(() => { useEffect(() => {
if (!showVersionModal && !showAdminModal) return; if (!showVersionModal) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); };
if (e.key === 'Escape') {
setShowVersionModal(false);
setShowAdminModal(false);
}
};
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [showVersionModal, showAdminModal]); }, [showVersionModal]);
// Check admin status on mount (cookie-based, survives reload)
useEffect(() => {
fetch('/api/admin/status', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.authenticated) setAdminLoggedIn(true); })
.catch(() => {});
}, []);
// Admin login handler
const handleAdminLogin = () => {
if (!adminPassword) return;
fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ password: adminPassword }),
})
.then(r => {
if (r.ok) {
setAdminLoggedIn(true);
setAdminPassword('');
setAdminError('');
setShowAdminModal(false);
} else {
setAdminError('Falsches Passwort');
}
})
.catch(() => setAdminError('Verbindungsfehler'));
};
const handleAdminLogout = () => {
fetch('/api/admin/logout', { method: 'POST', credentials: 'include' })
.then(() => {
setAdminLoggedIn(false);
setShowAdminModal(false);
})
.catch(() => {
setAdminLoggedIn(false);
setShowAdminModal(false);
});
};
// Tab icon mapping // Tab icon mapping
@ -215,103 +236,91 @@ export default function App() {
'game-library': '\u{1F3AE}', 'game-library': '\u{1F3AE}',
}; };
// Accent swatches configuration // What happens when the user button is clicked
const accentSwatches: { name: string; color: string }[] = [ function handleUserButtonClick() {
{ name: 'ember', color: '#e67e22' }, if (!user.authenticated) {
{ name: 'amethyst', color: '#8e44ad' }, setShowLoginModal(true);
{ name: 'ocean', color: '#2e86c1' }, } else if (isAdmin) {
{ name: 'jade', color: '#27ae60' }, setShowAdminPanel(true);
{ name: 'rose', color: '#e74c8b' }, } else if (isRegularUser) {
{ name: 'crimson', color: '#d63031' }, setShowUserSettings(true);
]; }
}
// Find active plugin for display
const activePlugin = plugins.find(p => p.name === activeTab);
return ( return (
<div className="app-shell" data-accent={accentTheme}> <div className="hub-app">
{/* ===== SIDEBAR ===== */} <header className="hub-header">
<aside className="app-sidebar"> <div className="hub-header-left">
{/* Sidebar Header: Logo + Brand */} <span className="hub-logo">{'\u{1F3AE}'}</span>
<div className="sidebar-header"> <span className="hub-title">Gaming Hub</span>
<div className="app-logo"> <span className={`hub-conn-dot ${connected ? 'online' : ''}`} />
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<rect x="6" y="3" width="12" height="18" rx="2" />
<path d="M9 18h6M12 7v4" />
</svg>
</div>
<span className="app-brand">Gaming Hub</span>
</div> </div>
{/* Channel Dropdown (static placeholder) */} <nav className="hub-tabs">
<div className="sidebar-channel">
<div className="channel-dropdown-trigger">
<svg className="channel-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" />
</svg>
<span className="channel-name">Sprechstunde</span>
<svg className="channel-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</div>
{/* Section Label */}
<div className="sidebar-section-label">Plugins</div>
{/* Navigation */}
<nav className="sidebar-nav">
{plugins.filter(p => p.name in tabComponents).map(p => ( {plugins.filter(p => p.name in tabComponents).map(p => (
<button <button
key={p.name} key={p.name}
className={`nav-item ${activeTab === p.name ? 'active' : ''}`} className={`hub-tab ${activeTab === p.name ? 'active' : ''}`}
onClick={() => setActiveTab(p.name)} onClick={() => setActiveTab(p.name)}
title={p.description} title={p.description}
> >
<span className="nav-icon">{tabIcons[p.name] || '\u{1F4E6}'}</span> <span className="hub-tab-icon">{tabIcons[p.name] ?? '\u{1F4E6}'}</span>
<span className="nav-label">{p.name}</span> <span className="hub-tab-label">{p.name}</span>
</button> </button>
))} ))}
</nav> </nav>
{/* Accent Theme Picker */} <div className="hub-header-right">
<div className="sidebar-accent-picker"> {!(window as any).electronAPI && (
{accentSwatches.map(swatch => ( <a
<button className="hub-download-btn"
key={swatch.name} href="/downloads/GamingHub-Setup.exe"
className={`accent-swatch ${accentTheme === swatch.name ? 'active' : ''}`} download
style={{ backgroundColor: swatch.color }} title="Desktop App herunterladen"
onClick={() => setAccentTheme(swatch.name)} >
title={swatch.name.charAt(0).toUpperCase() + swatch.name.slice(1)} <span className="hub-download-icon">{'\u2B07\uFE0F'}</span>
/> <span className="hub-download-label">Desktop App</span>
))} </a>
</div> )}
{/* Sidebar Footer: User + Connection + Settings + Admin */} {/* Unified Login / User button */}
<div className="sidebar-footer">
<div className="sidebar-avatar">
D
{connected && <span className={`status-dot ${connected ? 'online' : 'offline'}`} />}
</div>
<div className="sidebar-user-info">
<span className="sidebar-username">User</span>
<span className="sidebar-user-tag">
{connected ? 'Verbunden' : 'Getrennt'}
</span>
</div>
<button <button
className={`sidebar-settings ${adminLoggedIn ? 'admin-active' : ''}`} className={`hub-user-btn ${user.authenticated ? 'logged-in' : ''} ${isAdmin ? 'admin' : ''}`}
onClick={() => setShowAdminModal(true)} onClick={handleUserButtonClick}
title="Admin Login" onContextMenu={e => {
if (user.authenticated) { e.preventDefault(); handleLogout(); }
}}
title={
user.authenticated
? `${user.globalName || user.username || 'Admin'} (Rechtsklick = Abmelden)`
: 'Anmelden'
}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> {user.authenticated ? (
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> isRegularUser && user.avatar ? (
</svg> <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>
<button <button
className="sidebar-settings" className="hub-refresh-btn"
onClick={() => window.location.reload()}
title="Seite neu laden"
>
{'\u{1F504}'}
</button>
<span
className="hub-version hub-version-clickable"
onClick={() => { onClick={() => {
// Status vom Main-Prozess synchronisieren bevor Modal öffnet
if (isElectron) { if (isElectron) {
const api = (window as any).electronAPI; const api = (window as any).electronAPI;
const s = api.getUpdateStatus?.(); const s = api.getUpdateStatus?.();
@ -321,50 +330,14 @@ export default function App() {
} }
setShowVersionModal(true); setShowVersionModal(true);
}} }}
title="Einstellungen & Version" title="Versionsinformationen"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> v{version}
<circle cx="12" cy="12" r="3" /> </span>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</button>
</div> </div>
</aside> </header>
{/* ===== MAIN CONTENT ===== */}
<main className="app-main">
<div className="content-area">
{plugins.length === 0 ? (
<div className="hub-empty">
<span className="hub-empty-icon">{'\u{1F4E6}'}</span>
<h2>Keine Plugins geladen</h2>
<p>Plugins werden im Server konfiguriert.</p>
</div>
) : (
/* Render ALL tabs, hide inactive ones to preserve state.
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} isAdmin={adminLoggedIn} />
</div>
);
})
)}
</div>
</main>
{/* ===== VERSION MODAL ===== */}
{showVersionModal && ( {showVersionModal && (
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}> <div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
<div className="hub-version-modal" onClick={e => e.stopPropagation()}> <div className="hub-version-modal" onClick={e => e.stopPropagation()}>
@ -410,13 +383,13 @@ export default function App() {
{updateStatus === 'checking' && ( {updateStatus === 'checking' && (
<div className="hub-version-modal-update-status"> <div className="hub-version-modal-update-status">
<span className="hub-update-spinner" /> <span className="hub-update-spinner" />
Suche nach Updates... Suche nach Updates
</div> </div>
)} )}
{updateStatus === 'downloading' && ( {updateStatus === 'downloading' && (
<div className="hub-version-modal-update-status"> <div className="hub-version-modal-update-status">
<span className="hub-update-spinner" /> <span className="hub-update-spinner" />
Update wird heruntergeladen... Update wird heruntergeladen
</div> </div>
)} )}
{updateStatus === 'ready' && ( {updateStatus === 'ready' && (
@ -456,46 +429,64 @@ export default function App() {
</div> </div>
)} )}
{/* ===== ADMIN MODAL ===== */} {/* Login Modal */}
{showAdminModal && ( {showLoginModal && (
<div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}> <LoginModal
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}> onClose={() => setShowLoginModal(false)}
{adminLoggedIn ? ( onAdminLogin={handleAdminLogin}
<> providers={providers}
<div className="hub-admin-modal-title">Admin Panel</div> />
<div className="hub-admin-modal-info">
<div className="hub-admin-modal-avatar">A</div>
<div className="hub-admin-modal-text">
<span className="hub-admin-modal-name">Administrator</span>
<span className="hub-admin-modal-role">Eingeloggt</span>
</div>
</div>
<button className="hub-admin-modal-logout" onClick={handleAdminLogout}>
Ausloggen
</button>
</>
) : (
<>
<div className="hub-admin-modal-title">{'\u{1F511}'} Admin Login</div>
<div className="hub-admin-modal-subtitle">Passwort eingeben um Einstellungen freizuschalten</div>
{adminError && <div className="hub-admin-modal-error">{adminError}</div>}
<input
className="hub-admin-modal-input"
type="password"
placeholder="Passwort"
value={adminPassword}
onChange={e => setAdminPassword(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAdminLogin(); }}
autoFocus
/>
<button className="hub-admin-modal-login" onClick={handleAdminLogin}>
Login
</button>
</>
)}
</div>
</div>
)} )}
{/* 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">
<span className="hub-empty-icon">{'\u{1F4E6}'}</span>
<h2>Keine Plugins geladen</h2>
<p>Plugins werden im Server konfiguriert.</p>
</div>
) : (
/* Render ALL tabs, hide inactive ones to preserve state.
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} isAdmin={isAdmin} />
</div>
);
})
)}
</main>
</div> </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 COMPONENT
*/ */
export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) { export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
// ── State ── // ── State ──
const [profiles, setProfiles] = useState<ProfileSummary[]>([]); const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
@ -109,11 +109,9 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: {
const filterInputRef = useRef<HTMLInputElement>(null); const filterInputRef = useRef<HTMLInputElement>(null);
const [filterQuery, setFilterQuery] = useState(''); const [filterQuery, setFilterQuery] = useState('');
// ── Admin state ── // ── Admin (centralized in App.tsx) ──
const [showAdmin, setShowAdmin] = useState(false); const _isAdmin = isAdminProp ?? false;
const isAdmin = isAdminProp; void _isAdmin;
const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
// ── SSE data sync ── // ── SSE data sync ──
useEffect(() => { useEffect(() => {
@ -132,40 +130,6 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = 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 ── // ── Steam login ──
const connectSteam = useCallback(() => { const connectSteam = useCallback(() => {
const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600'); const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600');
@ -514,11 +478,6 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: {
</button> </button>
)} )}
<div className="gl-login-bar-spacer" /> <div className="gl-login-bar-spacer" />
{isAdmin && (
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
&#x2699;&#xFE0F;
</button>
)}
</div> </div>
{/* ── Profile Chips ── */} {/* ── Profile Chips ── */}
@ -945,54 +904,6 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: {
); );
})()} })()}
{/* ── 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>
<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>
</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) ── */} {/* ── GOG Code Dialog (browser fallback only) ── */}
{gogDialogOpen && ( {gogDialogOpen && (
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}> <div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>

View file

@ -472,29 +472,24 @@
/* ── Empty state ── */ /* ── Empty state ── */
.gl-empty { .gl-empty {
flex: 1; display: flex; flex-direction: column; text-align: center;
align-items: center; justify-content: center; gap: 16px; padding: 60px 20px;
padding: 40px; height: 100%;
} }
.gl-empty-icon { .gl-empty-icon {
font-size: 64px; line-height: 1; font-size: 48px;
animation: gl-empty-float 3s ease-in-out infinite; margin-bottom: 16px;
}
@keyframes gl-empty-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.gl-empty h3 { .gl-empty h3 {
font-size: 26px; font-weight: 700; color: #f2f3f5; color: var(--text-normal);
letter-spacing: -0.5px; margin: 0; margin: 0 0 8px;
} }
.gl-empty p { .gl-empty p {
font-size: 15px; color: #80848e; color: var(--text-faint);
text-align: center; max-width: 360px; line-height: 1.5; margin: 0; margin: 0;
font-size: 14px;
} }
/* ── Common game playtime chips ── */ /* ── Common game playtime chips ── */
@ -777,6 +772,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(4px);
} }
.gl-dialog { .gl-dialog {
@ -806,7 +802,7 @@
padding: 10px 12px; padding: 10px 12px;
background: #1a1810; background: #1a1810;
border: 1px solid #444; border: 1px solid #444;
border-radius: 6px; border-radius: 8px;
color: #fff; color: #fff;
font-size: 0.9rem; font-size: 0.9rem;
outline: none; outline: none;
@ -848,7 +844,7 @@
background: #322d26; background: #322d26;
color: #ccc; color: #ccc;
border: none; border: none;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -862,7 +858,7 @@
background: #a855f7; background: #a855f7;
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
@ -960,7 +956,7 @@
color: #fff; color: #fff;
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -55,7 +55,7 @@ const QUALITY_PRESETS = [
// ── Component ── // ── Component ──
export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) { export default function StreamingTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) {
// ── State ── // ── State ──
const [streams, setStreams] = useState<StreamInfo[]>([]); const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
@ -72,14 +72,9 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
// ── Admin / Notification Config ── // ── Admin ──
const [showAdmin, setShowAdmin] = useState(false); const _isAdmin = isAdminProp ?? false;
const isAdmin = isAdminProp; void _isAdmin; // kept for potential future use
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 });
// ── Refs ── // ── Refs ──
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@ -135,13 +130,6 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d
return () => document.removeEventListener('click', handler); return () => document.removeEventListener('click', handler);
}, [openMenu]); }, [openMenu]);
// Load notification bot status on mount
useEffect(() => {
fetch('/api/notifications/status')
.then(r => r.json())
.then(d => setNotifyStatus(d))
.catch(() => {});
}, []);
// ── Send via WS ── // ── Send via WS ──
const wsSend = useCallback((d: Record<string, any>) => { const wsSend = useCallback((d: Record<string, any>) => {
@ -604,69 +592,6 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d
}, [buildStreamLink]); }, [buildStreamLink]);
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 ── // ── Render ──
// Fullscreen viewer overlay // Fullscreen viewer overlay
@ -772,11 +697,6 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'} {starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
</button> </button>
)} )}
{isAdmin && (
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
{'\u2699\uFE0F'}
</button>
)}
</div> </div>
{streams.length === 0 && !isBroadcasting ? ( {streams.length === 0 && !isBroadcasting ? (
@ -881,80 +801,6 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d
</div> </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>
<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>
</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> </div>
); );
} }

View file

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

View file

@ -161,25 +161,23 @@
/* ── Empty state ── */ /* ── Empty state ── */
.wt-empty { .wt-empty {
flex: 1; display: flex; flex-direction: column; text-align: center;
align-items: center; justify-content: center; gap: 16px; padding: 60px 20px;
padding: 40px; height: 100%; color: var(--text-muted);
} }
.wt-empty-icon { .wt-empty-icon {
font-size: 64px; line-height: 1; font-size: 48px;
animation: wt-float 3s ease-in-out infinite; margin-bottom: 12px;
} opacity: 0.4;
@keyframes wt-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.wt-empty h3 { .wt-empty h3 {
font-size: 26px; font-weight: 700; color: #f2f3f5; font-size: 18px;
letter-spacing: -0.5px; margin: 0; font-weight: 600;
color: var(--text-normal);
margin-bottom: 6px;
} }
.wt-empty p { .wt-empty p {
font-size: 15px; color: #80848e; font-size: 14px;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Error ── */ /* ── Error ── */
@ -558,7 +556,7 @@
.wt-quality-select { .wt-quality-select {
background: var(--bg-secondary, #2a2620); background: var(--bg-secondary, #2a2620);
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #3a352d); border: 1px solid var(--border-color, #322d26);
border-radius: 6px; border-radius: 6px;
padding: 2px 6px; padding: 2px 6px;
font-size: 12px; font-size: 12px;

File diff suppressed because it is too large Load diff