Compare commits
42 commits
7d89ba6978
...
4e0d691aa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0d691aa1 | ||
|
|
32918e0a7a | ||
|
|
7f0b17291f | ||
|
|
4478ac6a6a | ||
|
|
7136aafec6 | ||
|
|
e1e2b9a1d8 | ||
|
|
1da6c76017 | ||
|
|
694f4371ce | ||
|
|
b8e4139a91 | ||
|
|
7fe9a16cd8 | ||
|
|
24e8a6b3f7 | ||
|
|
bf69827dbd | ||
|
|
d135aab6dc | ||
|
|
aa998c9b44 | ||
|
|
81c73407a0 | ||
|
|
6224db68b3 | ||
|
|
99d69f30ba | ||
|
|
a7e8407996 | ||
| 5ff0dad282 | |||
| 710081fe21 | |||
| 7e1b4e7860 | |||
| 9c483cedea | |||
| 5796a6d620 | |||
|
|
c24b4c5d9e | ||
|
|
7ed6b81584 | ||
|
|
25e47fb093 | ||
|
|
0bd31d93a8 | ||
|
|
c0956fbc7e | ||
|
|
8951f46536 | ||
|
|
354a9cd977 | ||
|
|
3f175ca02c | ||
|
|
4b23d013f9 | ||
|
|
e9931d82af | ||
|
|
bccfee3de2 | ||
|
|
b556863f52 | ||
|
|
65a1d6e869 | ||
|
|
e54f240523 | ||
|
|
a99dc4211c | ||
|
|
1bd0fa14bc | ||
|
|
b2f772208f | ||
|
|
966664d3a7 | ||
|
|
39e180aad4 |
30 changed files with 10070 additions and 9025 deletions
153
.forgejo/workflows/build-deploy.yml
Normal file
153
.forgejo/workflows/build-deploy.yml
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, nightly, feature/nightly]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: forgejo.adriahub.de
|
||||||
|
REGISTRY_MIRROR: forgejo.daddelolymp.de
|
||||||
|
IMAGE: root/gaming-hub
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker:latest
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"http://root:${{ secrets.PUSH_TOKEN }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" .
|
||||||
|
|
||||||
|
- name: Determine version and tag
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||||
|
BRANCH="${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
|
if [ "$BRANCH" = "main" ]; then
|
||||||
|
TAG="main"
|
||||||
|
CHANNEL="stable"
|
||||||
|
elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then
|
||||||
|
TAG="nightly"
|
||||||
|
VERSION="${VERSION}-nightly"
|
||||||
|
CHANNEL="nightly"
|
||||||
|
else
|
||||||
|
TAG=$(echo "$BRANCH" | sed 's/\//-/g')
|
||||||
|
VERSION="${VERSION}-dev"
|
||||||
|
CHANNEL="dev"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg "VITE_BUILD_CHANNEL=${{ steps.vars.outputs.channel }}" \
|
||||||
|
--build-arg "VITE_APP_VERSION=${{ steps.vars.outputs.version }}" \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push to registry (adriahub)
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
|
||||||
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Mirror to registry (daddelolymp)
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_DADDELOLYMP_PASSWORD }}" | docker login ${{ env.REGISTRY_MIRROR }} -u root --password-stdin
|
||||||
|
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \
|
||||||
|
${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
|
||||||
|
docker push ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
|
||||||
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest \
|
||||||
|
${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:latest
|
||||||
|
docker push ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:latest
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
if: github.ref_name == 'main'
|
||||||
|
container:
|
||||||
|
image: docker:latest
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
steps:
|
||||||
|
- name: Deploy container
|
||||||
|
run: |
|
||||||
|
DEPLOY_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE }}:latest"
|
||||||
|
CONTAINER_NAME="gaming-hub"
|
||||||
|
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin
|
||||||
|
docker pull "$DEPLOY_IMAGE"
|
||||||
|
docker stop "$CONTAINER_NAME" || true
|
||||||
|
docker rm "$CONTAINER_NAME" || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
--network pangolin \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8085:8080 \
|
||||||
|
-e TZ=Europe/Berlin \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e PORT=8080 \
|
||||||
|
-e DATA_DIR=/data \
|
||||||
|
-e SOUNDS_DIR=/data/sounds \
|
||||||
|
-e "NODE_OPTIONS=--dns-result-order=ipv4first" \
|
||||||
|
-e ADMIN_PWD="${{ secrets.GAMING_HUB_ADMIN_PWD }}" \
|
||||||
|
-e PCM_CACHE_MAX_MB=2048 \
|
||||||
|
-e DISCORD_TOKEN_JUKEBOX="${{ secrets.DISCORD_TOKEN_JUKEBOX }}" \
|
||||||
|
-e DISCORD_TOKEN_RADIO="${{ secrets.DISCORD_TOKEN_RADIO }}" \
|
||||||
|
-e DISCORD_TOKEN_NOTIFICATIONS="${{ secrets.DISCORD_TOKEN_NOTIFICATIONS }}" \
|
||||||
|
-e PUBLIC_URL="${{ secrets.PUBLIC_URL }}" \
|
||||||
|
-e STEAM_API_KEY="${{ secrets.STEAM_API_KEY }}" \
|
||||||
|
-e DISCORD_CLIENT_ID="${{ secrets.DISCORD_CLIENT_ID }}" \
|
||||||
|
-e DISCORD_CLIENT_SECRET="${{ secrets.DISCORD_CLIENT_SECRET }}" \
|
||||||
|
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
|
||||||
|
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
|
||||||
|
"$DEPLOY_IMAGE"
|
||||||
|
|
||||||
|
docker ps --filter name="$CONTAINER_NAME"
|
||||||
|
docker image prune -f || true
|
||||||
|
|
||||||
|
bump-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: deploy
|
||||||
|
if: github.ref_name == 'main' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
|
container:
|
||||||
|
image: alpine/git:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout and bump
|
||||||
|
run: |
|
||||||
|
git clone --branch main --depth 5 \
|
||||||
|
"http://root:${{ secrets.PUSH_TOKEN }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" repo
|
||||||
|
cd repo
|
||||||
|
git config user.name "Forgejo CI"
|
||||||
|
git config user.email "ci@adriahub.de"
|
||||||
|
|
||||||
|
VERSION=$(cat VERSION)
|
||||||
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||||
|
NEXT_PATCH=$((PATCH + 1))
|
||||||
|
NEXT_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}"
|
||||||
|
|
||||||
|
echo "$NEXT_VERSION" > VERSION
|
||||||
|
git add VERSION
|
||||||
|
git commit -m "v${NEXT_VERSION} [skip ci]"
|
||||||
|
git push origin main
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.8.2
|
1.8.18
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
359
server/src/core/discord-auth.ts
Normal file
359
server/src/core/discord-auth.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Unified Authentication: Discord OAuth2, Steam OpenID 2.0, Admin
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type express from 'express';
|
||||||
|
|
||||||
|
// ── Config ──
|
||||||
|
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? '';
|
||||||
|
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? '';
|
||||||
|
const DISCORD_API = 'https://discord.com/api/v10';
|
||||||
|
const DISCORD_AUTH_URL = 'https://discord.com/oauth2/authorize';
|
||||||
|
const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
|
||||||
|
const STEAM_API_KEY = process.env.STEAM_API_KEY ?? '';
|
||||||
|
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds
|
||||||
|
const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
export interface DiscordUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
discriminator: string;
|
||||||
|
avatar: string | null;
|
||||||
|
global_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
provider: 'discord' | 'steam' | 'admin';
|
||||||
|
discordId?: string;
|
||||||
|
steamId?: string;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
globalName?: string | null;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the generic user ID regardless of provider (discordId, steam:steamId, or 'admin') */
|
||||||
|
export function getUserId(session: UserSession): string | null {
|
||||||
|
if (session.discordId) return session.discordId;
|
||||||
|
if (session.steamId) return `steam:${session.steamId}`;
|
||||||
|
if (session.provider === 'admin') return 'admin';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
function b64url(input: Buffer | string): string {
|
||||||
|
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(req: express.Request, key: string): string | undefined {
|
||||||
|
const c = req.headers.cookie;
|
||||||
|
if (!c) return undefined;
|
||||||
|
for (const part of c.split(';')) {
|
||||||
|
const [k, v] = part.trim().split('=');
|
||||||
|
if (k === key) return decodeURIComponent(v || '');
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session Token (HMAC-SHA256) ──
|
||||||
|
// Uses ADMIN_PWD as base secret, with a salt to differentiate from admin tokens
|
||||||
|
const SESSION_SECRET = (process.env.ADMIN_PWD ?? '') + ':hub_session_v1';
|
||||||
|
|
||||||
|
export function signSession(session: UserSession): string {
|
||||||
|
const body = b64url(JSON.stringify(session));
|
||||||
|
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
|
||||||
|
return `${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySession(token: string | undefined): UserSession | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const [body, sig] = token.split('.');
|
||||||
|
if (!body || !sig) return null;
|
||||||
|
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
|
||||||
|
if (expected !== sig) return null;
|
||||||
|
try {
|
||||||
|
const session = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as UserSession;
|
||||||
|
if (typeof session.exp === 'number' && Date.now() < session.exp) return session;
|
||||||
|
return null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(req: express.Request): UserSession | null {
|
||||||
|
return verifySession(readCookie(req, 'hub_session'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Token (backward compat with soundboard plugin) ──
|
||||||
|
function signAdminTokenCompat(adminPwd: string): string {
|
||||||
|
const payload = { iat: Date.now(), exp: Date.now() + ADMIN_MAX_AGE * 1000 };
|
||||||
|
const body = b64url(JSON.stringify(payload));
|
||||||
|
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
|
||||||
|
return `${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Discord OAuth2 ──
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
const publicUrl = process.env.PUBLIC_URL ?? '';
|
||||||
|
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/discord/callback`;
|
||||||
|
return `http://localhost:${process.env.PORT ?? 8080}/api/auth/discord/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDiscordConfigured(): boolean {
|
||||||
|
return !!(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscordAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: DISCORD_CLIENT_ID,
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'identify',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
return `${DISCORD_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeDiscordCode(code: string): Promise<string> {
|
||||||
|
const res = await fetch(DISCORD_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: DISCORD_CLIENT_ID,
|
||||||
|
client_secret: DISCORD_CLIENT_SECRET,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Discord token exchange failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const data = await res.json() as { access_token: string };
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
|
||||||
|
const res = await fetch(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Discord user fetch failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return await res.json() as DiscordUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steam OpenID 2.0 ──
|
||||||
|
export function isSteamConfigured(): boolean {
|
||||||
|
return !!STEAM_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSteamReturnUrl(req: express.Request): string {
|
||||||
|
const publicUrl = process.env.PUBLIC_URL ?? '';
|
||||||
|
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/steam/callback`;
|
||||||
|
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||||
|
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
||||||
|
return `${proto}://${host}/api/auth/steam/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSteamRealm(req: express.Request): string {
|
||||||
|
const publicUrl = process.env.PUBLIC_URL ?? '';
|
||||||
|
if (publicUrl) return publicUrl.replace(/\/$/, '');
|
||||||
|
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||||
|
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySteamOpenId(query: Record<string, string>): Promise<string | null> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
params.set('openid.mode', 'check_authentication');
|
||||||
|
|
||||||
|
const resp = await fetch(`https://steamcommunity.com/openid/login?${params.toString()}`);
|
||||||
|
const text = await resp.text();
|
||||||
|
if (!text.includes('is_valid:true')) return null;
|
||||||
|
|
||||||
|
const claimedId = String(query['openid.claimed_id'] || '');
|
||||||
|
const match = claimedId.match(/\/id\/(\d+)$/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string }> {
|
||||||
|
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`Steam API error: ${resp.status}`);
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
const player = json?.response?.players?.[0];
|
||||||
|
return {
|
||||||
|
personaName: player?.personaname || steamId,
|
||||||
|
avatarUrl: player?.avatarfull || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Register Routes ──
|
||||||
|
export function registerAuthRoutes(app: express.Application, adminPwd: string): void {
|
||||||
|
|
||||||
|
// Available providers
|
||||||
|
app.get('/api/auth/providers', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
discord: isDiscordConfigured(),
|
||||||
|
steam: isSteamConfigured(),
|
||||||
|
admin: !!adminPwd,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current session
|
||||||
|
app.get('/api/auth/me', (req, res) => {
|
||||||
|
const session = getSession(req);
|
||||||
|
if (!session) {
|
||||||
|
res.json({ authenticated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
authenticated: true,
|
||||||
|
provider: session.provider,
|
||||||
|
discordId: session.discordId ?? null,
|
||||||
|
steamId: session.steamId ?? null,
|
||||||
|
username: session.username ?? null,
|
||||||
|
avatar: session.avatar ?? null,
|
||||||
|
globalName: session.globalName ?? null,
|
||||||
|
isAdmin: session.provider === 'admin',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discord OAuth2 — start
|
||||||
|
app.get('/api/auth/discord', (_req, res) => {
|
||||||
|
if (!isDiscordConfigured()) {
|
||||||
|
res.status(503).json({ error: 'Discord OAuth nicht konfiguriert (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET fehlen)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
console.log(`[Auth] Discord OAuth2 redirect → ${getRedirectUri()}`);
|
||||||
|
res.redirect(getDiscordAuthUrl(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discord OAuth2 — callback
|
||||||
|
app.get('/api/auth/discord/callback', async (req, res) => {
|
||||||
|
const code = req.query.code as string | undefined;
|
||||||
|
if (!code) {
|
||||||
|
res.status(400).send('Kein Authorization-Code erhalten.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const accessToken = await exchangeDiscordCode(code);
|
||||||
|
const user = await fetchDiscordUser(accessToken);
|
||||||
|
|
||||||
|
const avatarUrl = user.avatar
|
||||||
|
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const session: UserSession = {
|
||||||
|
provider: 'discord',
|
||||||
|
discordId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
globalName: user.global_name,
|
||||||
|
iat: Date.now(),
|
||||||
|
exp: Date.now() + SESSION_MAX_AGE * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = signSession(session);
|
||||||
|
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
|
||||||
|
console.log(`[Auth] Discord login: ${user.username} (${user.id})`);
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Auth] Discord callback error:', e);
|
||||||
|
res.status(500).send('Discord Login fehlgeschlagen. Bitte erneut versuchen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Steam OpenID 2.0 — start
|
||||||
|
app.get('/api/auth/steam', (req, res) => {
|
||||||
|
if (!isSteamConfigured()) {
|
||||||
|
res.status(503).json({ error: 'Steam nicht konfiguriert (STEAM_API_KEY fehlt)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const realm = getSteamRealm(req);
|
||||||
|
const returnTo = getSteamReturnUrl(req);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||||
|
'openid.mode': 'checkid_setup',
|
||||||
|
'openid.return_to': returnTo,
|
||||||
|
'openid.realm': realm,
|
||||||
|
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||||
|
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Auth] Steam OpenID redirect → ${returnTo}`);
|
||||||
|
res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Steam OpenID 2.0 — callback
|
||||||
|
app.get('/api/auth/steam/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const steamId = await verifySteamOpenId(req.query as Record<string, string>);
|
||||||
|
if (!steamId) {
|
||||||
|
res.status(403).send('Steam-Verifizierung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await fetchSteamProfile(steamId);
|
||||||
|
|
||||||
|
const session: UserSession = {
|
||||||
|
provider: 'steam',
|
||||||
|
steamId,
|
||||||
|
username: profile.personaName,
|
||||||
|
avatar: profile.avatarUrl || null,
|
||||||
|
iat: Date.now(),
|
||||||
|
exp: Date.now() + SESSION_MAX_AGE * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = signSession(session);
|
||||||
|
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
|
||||||
|
console.log(`[Auth] Steam login: ${profile.personaName} (${steamId})`);
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Auth] Steam callback error:', e);
|
||||||
|
res.status(500).send('Steam Login fehlgeschlagen. Bitte erneut versuchen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin login (via unified modal)
|
||||||
|
app.post('/api/auth/admin', (req, res) => {
|
||||||
|
if (!adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
|
||||||
|
const { password } = req.body ?? {};
|
||||||
|
if (!password || password !== adminPwd) {
|
||||||
|
res.status(401).json({ error: 'Falsches Passwort' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session: UserSession = {
|
||||||
|
provider: 'admin',
|
||||||
|
username: 'Admin',
|
||||||
|
iat: Date.now(),
|
||||||
|
exp: Date.now() + ADMIN_MAX_AGE * 1000,
|
||||||
|
};
|
||||||
|
const hubToken = signSession(session);
|
||||||
|
const adminToken = signAdminTokenCompat(adminPwd);
|
||||||
|
// Set hub_session AND legacy admin cookie (soundboard plugin reads 'admin' cookie)
|
||||||
|
res.setHeader('Set-Cookie', [
|
||||||
|
`hub_session=${encodeURIComponent(hubToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
|
||||||
|
`admin=${encodeURIComponent(adminToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
|
||||||
|
]);
|
||||||
|
console.log('[Auth] Admin login');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout (clears all session cookies)
|
||||||
|
app.post('/api/auth/logout', (_req, res) => {
|
||||||
|
res.setHeader('Set-Cookie', [
|
||||||
|
'hub_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
|
||||||
|
'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
|
||||||
|
]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
1
web/dist/assets/index-BStrUazC.css
vendored
1
web/dist/assets/index-BStrUazC.css
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/index-BrwtipcK.css
vendored
Normal file
1
web/dist/assets/index-BrwtipcK.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4830
web/dist/assets/index-CG_5yn3u.js
vendored
4830
web/dist/assets/index-CG_5yn3u.js
vendored
File diff suppressed because one or more lines are too long
4830
web/dist/assets/index-CqHVUt2T.js
vendored
Normal file
4830
web/dist/assets/index-CqHVUt2T.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gaming Hub</title>
|
<title>Gaming Hub</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
|
||||||
<script type="module" crossorigin src="/assets/index-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
664
web/src/AdminPanel.tsx
Normal file
|
|
@ -0,0 +1,664 @@
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
TYPES
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
type Sound = {
|
||||||
|
fileName: string;
|
||||||
|
name: string;
|
||||||
|
folder?: string;
|
||||||
|
relativePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SoundsResponse = {
|
||||||
|
items: Sound[];
|
||||||
|
total: number;
|
||||||
|
folders: Array<{ key: string; name: string; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AdminPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
API HELPERS
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const SB_API = '/api/soundboard';
|
||||||
|
|
||||||
|
async function fetchAllSounds(): Promise<SoundsResponse> {
|
||||||
|
const url = new URL(`${SB_API}/sounds`, window.location.origin);
|
||||||
|
url.searchParams.set('folder', '__all__');
|
||||||
|
url.searchParams.set('fuzzy', '0');
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden der Sounds');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiAdminDelete(paths: string[]): Promise<void> {
|
||||||
|
const res = await fetch(`${SB_API}/admin/sounds/delete`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
|
body: JSON.stringify({ paths }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Loeschen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiAdminRename(from: string, to: string): Promise<string> {
|
||||||
|
const res = await fetch(`${SB_API}/admin/sounds/rename`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
|
body: JSON.stringify({ from, to }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Umbenennen fehlgeschlagen');
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.to as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiUploadFile(
|
||||||
|
file: File,
|
||||||
|
onProgress: (pct: number) => void,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('files', file);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', `${SB_API}/upload`);
|
||||||
|
xhr.upload.onprogress = e => {
|
||||||
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
resolve(data.files?.[0]?.name ?? file.name);
|
||||||
|
} catch { resolve(file.name); }
|
||||||
|
} else {
|
||||||
|
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
|
||||||
|
catch { reject(new Error(`HTTP ${xhr.status}`)); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('Netzwerkfehler'));
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
COMPONENT
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
type AdminTab = 'soundboard' | 'streaming' | 'game-library';
|
||||||
|
|
||||||
|
export default function AdminPanel({ onClose, onLogout }: AdminPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<AdminTab>('soundboard');
|
||||||
|
|
||||||
|
// ── Toast ──
|
||||||
|
const [toast, setToast] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
||||||
|
const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => {
|
||||||
|
setToast({ msg, type });
|
||||||
|
setTimeout(() => setToast(null), 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Escape key ──
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
SOUNDBOARD ADMIN STATE
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
const [sbSounds, setSbSounds] = useState<Sound[]>([]);
|
||||||
|
const [sbLoading, setSbLoading] = useState(false);
|
||||||
|
const [sbQuery, setSbQuery] = useState('');
|
||||||
|
const [sbSelection, setSbSelection] = useState<Record<string, boolean>>({});
|
||||||
|
const [sbRenameTarget, setSbRenameTarget] = useState('');
|
||||||
|
const [sbRenameValue, setSbRenameValue] = useState('');
|
||||||
|
const [sbUploadProgress, setSbUploadProgress] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []);
|
||||||
|
|
||||||
|
const loadSbSounds = useCallback(async () => {
|
||||||
|
setSbLoading(true);
|
||||||
|
try {
|
||||||
|
const d = await fetchAllSounds();
|
||||||
|
setSbSounds(d.items || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
notify(e?.message || 'Sounds konnten nicht geladen werden', 'error');
|
||||||
|
} finally {
|
||||||
|
setSbLoading(false);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
// Load on first tab switch
|
||||||
|
const [sbLoaded, setSbLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'soundboard' && !sbLoaded) {
|
||||||
|
setSbLoaded(true);
|
||||||
|
void loadSbSounds();
|
||||||
|
}
|
||||||
|
}, [activeTab, sbLoaded, loadSbSounds]);
|
||||||
|
|
||||||
|
const sbFiltered = useMemo(() => {
|
||||||
|
const q = sbQuery.trim().toLowerCase();
|
||||||
|
if (!q) return sbSounds;
|
||||||
|
return sbSounds.filter(s => {
|
||||||
|
const key = soundKey(s).toLowerCase();
|
||||||
|
return s.name.toLowerCase().includes(q)
|
||||||
|
|| (s.folder || '').toLowerCase().includes(q)
|
||||||
|
|| key.includes(q);
|
||||||
|
});
|
||||||
|
}, [sbQuery, sbSounds, soundKey]);
|
||||||
|
|
||||||
|
const sbSelectedPaths = useMemo(() =>
|
||||||
|
Object.keys(sbSelection).filter(k => sbSelection[k]),
|
||||||
|
[sbSelection]);
|
||||||
|
|
||||||
|
const sbSelectedVisibleCount = useMemo(() =>
|
||||||
|
sbFiltered.filter(s => !!sbSelection[soundKey(s)]).length,
|
||||||
|
[sbFiltered, sbSelection, soundKey]);
|
||||||
|
|
||||||
|
const sbAllVisibleSelected = sbFiltered.length > 0 && sbSelectedVisibleCount === sbFiltered.length;
|
||||||
|
|
||||||
|
function sbToggleSelection(path: string) {
|
||||||
|
setSbSelection(prev => ({ ...prev, [path]: !prev[path] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sbStartRename(sound: Sound) {
|
||||||
|
setSbRenameTarget(soundKey(sound));
|
||||||
|
setSbRenameValue(sound.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sbCancelRename() {
|
||||||
|
setSbRenameTarget('');
|
||||||
|
setSbRenameValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sbSubmitRename() {
|
||||||
|
if (!sbRenameTarget) return;
|
||||||
|
const baseName = sbRenameValue.trim().replace(/\.(mp3|wav)$/i, '');
|
||||||
|
if (!baseName) {
|
||||||
|
notify('Bitte einen gueltigen Namen eingeben', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiAdminRename(sbRenameTarget, baseName);
|
||||||
|
notify('Sound umbenannt');
|
||||||
|
sbCancelRename();
|
||||||
|
await loadSbSounds();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify(e?.message || 'Umbenennen fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sbDeletePaths(paths: string[]) {
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
try {
|
||||||
|
await apiAdminDelete(paths);
|
||||||
|
notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`);
|
||||||
|
setSbSelection({});
|
||||||
|
sbCancelRename();
|
||||||
|
await loadSbSounds();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sbUpload(file: File) {
|
||||||
|
setSbUploadProgress(0);
|
||||||
|
try {
|
||||||
|
await apiUploadFile(file, pct => setSbUploadProgress(pct));
|
||||||
|
notify(`"${file.name}" hochgeladen`);
|
||||||
|
await loadSbSounds();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify(e?.message || 'Upload fehlgeschlagen', 'error');
|
||||||
|
} finally {
|
||||||
|
setSbUploadProgress(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
STREAMING ADMIN STATE
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
const [stAvailableChannels, setStAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]);
|
||||||
|
const [stNotifyConfig, setStNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]);
|
||||||
|
const [stConfigLoading, setStConfigLoading] = useState(false);
|
||||||
|
const [stConfigSaving, setStConfigSaving] = useState(false);
|
||||||
|
const [stNotifyStatus, setStNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null });
|
||||||
|
|
||||||
|
const loadStreamingConfig = useCallback(async () => {
|
||||||
|
setStConfigLoading(true);
|
||||||
|
try {
|
||||||
|
const [statusResp, chResp, cfgResp] = await Promise.all([
|
||||||
|
fetch('/api/notifications/status'),
|
||||||
|
fetch('/api/notifications/channels', { credentials: 'include' }),
|
||||||
|
fetch('/api/notifications/config', { credentials: 'include' }),
|
||||||
|
]);
|
||||||
|
if (statusResp.ok) {
|
||||||
|
const d = await statusResp.json();
|
||||||
|
setStNotifyStatus(d);
|
||||||
|
}
|
||||||
|
if (chResp.ok) {
|
||||||
|
const chData = await chResp.json();
|
||||||
|
setStAvailableChannels(chData.channels || []);
|
||||||
|
}
|
||||||
|
if (cfgResp.ok) {
|
||||||
|
const cfgData = await cfgResp.json();
|
||||||
|
setStNotifyConfig(cfgData.channels || []);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
finally { setStConfigLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [stLoaded, setStLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'streaming' && !stLoaded) {
|
||||||
|
setStLoaded(true);
|
||||||
|
void loadStreamingConfig();
|
||||||
|
}
|
||||||
|
}, [activeTab, stLoaded, loadStreamingConfig]);
|
||||||
|
|
||||||
|
const stToggleEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
|
||||||
|
setStNotifyConfig(prev => {
|
||||||
|
const existing = prev.find(c => c.channelId === channelId);
|
||||||
|
if (existing) {
|
||||||
|
const hasEvent = existing.events.includes(event);
|
||||||
|
const newEvents = hasEvent
|
||||||
|
? existing.events.filter(e => e !== event)
|
||||||
|
: [...existing.events, event];
|
||||||
|
if (newEvents.length === 0) {
|
||||||
|
return prev.filter(c => c.channelId !== channelId);
|
||||||
|
}
|
||||||
|
return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c);
|
||||||
|
} else {
|
||||||
|
return [...prev, { channelId, channelName, guildId, guildName, events: [event] }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stIsEnabled = useCallback((channelId: string, event: string): boolean => {
|
||||||
|
const ch = stNotifyConfig.find(c => c.channelId === channelId);
|
||||||
|
return ch?.events.includes(event) ?? false;
|
||||||
|
}, [stNotifyConfig]);
|
||||||
|
|
||||||
|
const stSaveConfig = useCallback(async () => {
|
||||||
|
setStConfigSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channels: stNotifyConfig }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
notify('Konfiguration gespeichert');
|
||||||
|
} catch {
|
||||||
|
notify('Speichern fehlgeschlagen', 'error');
|
||||||
|
} finally {
|
||||||
|
setStConfigSaving(false);
|
||||||
|
}
|
||||||
|
}, [stNotifyConfig, notify]);
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
GAME LIBRARY ADMIN STATE
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
const [glProfiles, setGlProfiles] = useState<any[]>([]);
|
||||||
|
const [glLoading, setGlLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadGlProfiles = useCallback(async () => {
|
||||||
|
setGlLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' });
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = await resp.json();
|
||||||
|
setGlProfiles(d.profiles || []);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
finally { setGlLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [glLoaded, setGlLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'game-library' && !glLoaded) {
|
||||||
|
setGlLoaded(true);
|
||||||
|
void loadGlProfiles();
|
||||||
|
}
|
||||||
|
}, [activeTab, glLoaded, loadGlProfiles]);
|
||||||
|
|
||||||
|
const glDeleteProfile = useCallback(async (profileId: string, displayName: string) => {
|
||||||
|
if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
notify('Profil geloescht');
|
||||||
|
loadGlProfiles();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify('Loeschen fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}, [loadGlProfiles, notify]);
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
TAB CONFIG
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const tabs: { id: AdminTab; icon: string; label: string }[] = [
|
||||||
|
{ id: 'soundboard', icon: '\uD83C\uDFB5', label: 'Soundboard' },
|
||||||
|
{ id: 'streaming', icon: '\uD83D\uDCFA', label: 'Streaming' },
|
||||||
|
{ id: 'game-library', icon: '\uD83C\uDFAE', label: 'Game Library' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
RENDER
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ap-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className="ap-modal">
|
||||||
|
{/* ── Sidebar ── */}
|
||||||
|
<div className="ap-sidebar">
|
||||||
|
<div className="ap-sidebar-title">{'\u2699\uFE0F'} Admin</div>
|
||||||
|
<nav className="ap-nav">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`ap-nav-item ${activeTab === t.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
>
|
||||||
|
<span className="ap-nav-icon">{t.icon}</span>
|
||||||
|
<span className="ap-nav-label">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<button className="ap-logout-btn" onClick={onLogout}>
|
||||||
|
{'\uD83D\uDD12'} Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div className="ap-content">
|
||||||
|
<div className="ap-header">
|
||||||
|
<h2 className="ap-title">
|
||||||
|
{tabs.find(t => t.id === activeTab)?.icon}{' '}
|
||||||
|
{tabs.find(t => t.id === activeTab)?.label}
|
||||||
|
</h2>
|
||||||
|
<button className="ap-close" onClick={onClose}>{'\u2715'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ap-body">
|
||||||
|
{/* ═══════════════════ SOUNDBOARD TAB ═══════════════════ */}
|
||||||
|
{activeTab === 'soundboard' && (
|
||||||
|
<div className="ap-tab-content">
|
||||||
|
<div className="ap-toolbar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ap-search"
|
||||||
|
value={sbQuery}
|
||||||
|
onChange={e => setSbQuery(e.target.value)}
|
||||||
|
placeholder="Nach Name, Ordner oder Pfad filtern..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-outline"
|
||||||
|
onClick={() => { void loadSbSounds(); }}
|
||||||
|
disabled={sbLoading}
|
||||||
|
>
|
||||||
|
{'\u21BB'} Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<label className="ap-upload-zone">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".mp3,.wav"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) sbUpload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{sbUploadProgress !== null ? (
|
||||||
|
<span className="ap-upload-progress">Upload: {sbUploadProgress}%</span>
|
||||||
|
) : (
|
||||||
|
<span className="ap-upload-text">{'\u2B06\uFE0F'} Datei hochladen (MP3 / WAV)</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
<div className="ap-bulk-row">
|
||||||
|
<label className="ap-select-all">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sbAllVisibleSelected}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const next = { ...sbSelection };
|
||||||
|
sbFiltered.forEach(s => { next[soundKey(s)] = checked; });
|
||||||
|
setSbSelection(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Alle sichtbaren ({sbSelectedVisibleCount}/{sbFiltered.length})</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-danger"
|
||||||
|
disabled={sbSelectedPaths.length === 0}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Wirklich ${sbSelectedPaths.length} Sound(s) loeschen?`)) return;
|
||||||
|
await sbDeletePaths(sbSelectedPaths);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDDD1\uFE0F'} Ausgewaehlte loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sound list */}
|
||||||
|
<div className="ap-list-wrap">
|
||||||
|
{sbLoading ? (
|
||||||
|
<div className="ap-empty">Lade Sounds...</div>
|
||||||
|
) : sbFiltered.length === 0 ? (
|
||||||
|
<div className="ap-empty">Keine Sounds gefunden.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ap-list">
|
||||||
|
{sbFiltered.map(sound => {
|
||||||
|
const key = soundKey(sound);
|
||||||
|
const editing = sbRenameTarget === key;
|
||||||
|
return (
|
||||||
|
<div className="ap-item" key={key}>
|
||||||
|
<label className="ap-item-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!sbSelection[key]}
|
||||||
|
onChange={() => sbToggleSelection(key)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="ap-item-main">
|
||||||
|
<div className="ap-item-name">{sound.name}</div>
|
||||||
|
<div className="ap-item-meta">
|
||||||
|
{sound.folder ? `Ordner: ${sound.folder}` : 'Root'}
|
||||||
|
{' \u00B7 '}
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<div className="ap-rename-row">
|
||||||
|
<input
|
||||||
|
className="ap-rename-input"
|
||||||
|
value={sbRenameValue}
|
||||||
|
onChange={e => setSbRenameValue(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') void sbSubmitRename();
|
||||||
|
if (e.key === 'Escape') sbCancelRename();
|
||||||
|
}}
|
||||||
|
placeholder="Neuer Name..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="ap-btn ap-btn-primary ap-btn-sm" onClick={() => { void sbSubmitRename(); }}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button className="ap-btn ap-btn-outline ap-btn-sm" onClick={sbCancelRename}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!editing && (
|
||||||
|
<div className="ap-item-actions">
|
||||||
|
<button className="ap-btn ap-btn-outline ap-btn-sm" onClick={() => sbStartRename(sound)}>
|
||||||
|
Umbenennen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-danger ap-btn-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Sound "${sound.name}" loeschen?`)) return;
|
||||||
|
await sbDeletePaths([key]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════════ STREAMING TAB ═══════════════════ */}
|
||||||
|
{activeTab === 'streaming' && (
|
||||||
|
<div className="ap-tab-content">
|
||||||
|
<div className="ap-toolbar">
|
||||||
|
<span className="ap-status-badge">
|
||||||
|
<span className={`ap-status-dot ${stNotifyStatus.online ? 'online' : ''}`} />
|
||||||
|
{stNotifyStatus.online
|
||||||
|
? <>Bot online: <b>{stNotifyStatus.botTag}</b></>
|
||||||
|
: <>Bot offline</>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-outline"
|
||||||
|
onClick={() => { void loadStreamingConfig(); }}
|
||||||
|
disabled={stConfigLoading}
|
||||||
|
>
|
||||||
|
{'\u21BB'} Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stConfigLoading ? (
|
||||||
|
<div className="ap-empty">Lade Kanaele...</div>
|
||||||
|
) : stAvailableChannels.length === 0 ? (
|
||||||
|
<div className="ap-empty">
|
||||||
|
{stNotifyStatus.online
|
||||||
|
? 'Keine Text-Kanaele gefunden. Bot hat moeglicherweise keinen Zugriff.'
|
||||||
|
: 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="ap-hint">
|
||||||
|
Waehle die Kanaele, in die Benachrichtigungen gesendet werden sollen:
|
||||||
|
</p>
|
||||||
|
<div className="ap-channel-list">
|
||||||
|
{stAvailableChannels.map(ch => (
|
||||||
|
<div key={ch.channelId} className="ap-channel-row">
|
||||||
|
<div className="ap-channel-info">
|
||||||
|
<span className="ap-channel-name">#{ch.channelName}</span>
|
||||||
|
<span className="ap-channel-guild">{ch.guildName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ap-channel-toggles">
|
||||||
|
<label className={`ap-toggle ${stIsEnabled(ch.channelId, 'stream_start') ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={stIsEnabled(ch.channelId, 'stream_start')}
|
||||||
|
onChange={() => stToggleEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_start')}
|
||||||
|
/>
|
||||||
|
{'\uD83D\uDD34'} Stream Start
|
||||||
|
</label>
|
||||||
|
<label className={`ap-toggle ${stIsEnabled(ch.channelId, 'stream_end') ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={stIsEnabled(ch.channelId, 'stream_end')}
|
||||||
|
onChange={() => stToggleEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_end')}
|
||||||
|
/>
|
||||||
|
{'\u23F9\uFE0F'} Stream Ende
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ap-save-row">
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-primary"
|
||||||
|
onClick={stSaveConfig}
|
||||||
|
disabled={stConfigSaving}
|
||||||
|
>
|
||||||
|
{stConfigSaving ? 'Speichern...' : '\uD83D\uDCBE Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════════ GAME LIBRARY TAB ═══════════════════ */}
|
||||||
|
{activeTab === 'game-library' && (
|
||||||
|
<div className="ap-tab-content">
|
||||||
|
<div className="ap-toolbar">
|
||||||
|
<span className="ap-status-badge">
|
||||||
|
<span className="ap-status-dot online" />
|
||||||
|
Eingeloggt als Admin
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-outline"
|
||||||
|
onClick={() => { void loadGlProfiles(); }}
|
||||||
|
disabled={glLoading}
|
||||||
|
>
|
||||||
|
{'\u21BB'} Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{glLoading ? (
|
||||||
|
<div className="ap-empty">Lade Profile...</div>
|
||||||
|
) : glProfiles.length === 0 ? (
|
||||||
|
<div className="ap-empty">Keine Profile vorhanden.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ap-profile-list">
|
||||||
|
{glProfiles.map((p: any) => (
|
||||||
|
<div key={p.id} className="ap-profile-row">
|
||||||
|
<img className="ap-profile-avatar" src={p.avatarUrl} alt={p.displayName} />
|
||||||
|
<div className="ap-profile-info">
|
||||||
|
<span className="ap-profile-name">{p.displayName}</span>
|
||||||
|
<span className="ap-profile-details">
|
||||||
|
{p.steamName && <span className="ap-platform-badge steam">Steam: {p.steamGames}</span>}
|
||||||
|
{p.gogName && <span className="ap-platform-badge gog">GOG: {p.gogGames}</span>}
|
||||||
|
<span className="ap-profile-total">{p.totalGames} Spiele</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ap-btn ap-btn-danger ap-btn-sm"
|
||||||
|
onClick={() => glDeleteProfile(p.id, p.displayName)}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDDD1\uFE0F'} Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Toast ── */}
|
||||||
|
{toast && (
|
||||||
|
<div className={`ap-toast ${toast.type}`}>
|
||||||
|
{toast.type === 'error' ? '\u274C' : '\u2705'} {toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
web/src/App.tsx
435
web/src/App.tsx
|
|
@ -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
125
web/src/LoginModal.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onAdminLogin: (password: string) => Promise<boolean>;
|
||||||
|
providers: { discord: boolean; steam: boolean; admin: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginModal({ onClose, onAdminLogin, providers }: LoginModalProps) {
|
||||||
|
const [showAdminForm, setShowAdminForm] = useState(false);
|
||||||
|
const [adminPwd, setAdminPwd] = useState('');
|
||||||
|
const [adminError, setAdminError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showAdminForm) setShowAdminForm(false);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose, showAdminForm]);
|
||||||
|
|
||||||
|
async function handleAdminSubmit() {
|
||||||
|
if (!adminPwd.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setAdminError('');
|
||||||
|
const ok = await onAdminLogin(adminPwd);
|
||||||
|
setLoading(false);
|
||||||
|
if (ok) {
|
||||||
|
setAdminPwd('');
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setAdminError('Falsches Passwort');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hub-login-overlay" onClick={onClose}>
|
||||||
|
<div className="hub-login-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="hub-login-modal-header">
|
||||||
|
<span>{'\uD83D\uDD10'} Anmelden</span>
|
||||||
|
<button className="hub-login-modal-close" onClick={onClose}>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showAdminForm ? (
|
||||||
|
<div className="hub-login-modal-body">
|
||||||
|
<p className="hub-login-subtitle">Melde dich an, um deine Einstellungen zu verwalten.</p>
|
||||||
|
|
||||||
|
<div className="hub-login-providers">
|
||||||
|
{/* Discord */}
|
||||||
|
{providers.discord && (
|
||||||
|
<a href="/api/auth/discord" className="hub-login-provider-btn discord">
|
||||||
|
<svg className="hub-login-provider-icon" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||||
|
</svg>
|
||||||
|
<span>Mit Discord anmelden</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Steam */}
|
||||||
|
{providers.steam && (
|
||||||
|
<a href="/api/auth/steam" className="hub-login-provider-btn steam">
|
||||||
|
<svg className="hub-login-provider-icon" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||||
|
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 12.001-5.373 12.001-12S18.606 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303a3.015 3.015 0 0 0-3.016-3.016 3.015 3.015 0 0 0-3.016 3.016 3.015 3.015 0 0 0 3.016 3.016 3.015 3.015 0 0 0 3.016-3.016zm-5.273-.005c0-1.248 1.013-2.26 2.26-2.26 1.246 0 2.26 1.013 2.26 2.26 0 1.247-1.014 2.26-2.26 2.26-1.248 0-2.26-1.013-2.26-2.26z" />
|
||||||
|
</svg>
|
||||||
|
<span>Mit Steam anmelden</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
{providers.admin && (
|
||||||
|
<button
|
||||||
|
className="hub-login-provider-btn admin"
|
||||||
|
onClick={() => setShowAdminForm(true)}
|
||||||
|
>
|
||||||
|
<span className="hub-login-provider-icon-emoji">{'\uD83D\uDD27'}</span>
|
||||||
|
<span>Admin Login</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!providers.discord && (
|
||||||
|
<p className="hub-login-hint">
|
||||||
|
{'\u2139\uFE0F'} Discord Login ist nicht konfiguriert. Der Server braucht DISCORD_CLIENT_ID und DISCORD_CLIENT_SECRET.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="hub-login-modal-body">
|
||||||
|
<button className="hub-login-back" onClick={() => { setShowAdminForm(false); setAdminError(''); }}>
|
||||||
|
{'\u2190'} Zurück
|
||||||
|
</button>
|
||||||
|
<div className="hub-login-admin-form">
|
||||||
|
<label className="hub-login-admin-label">{'\uD83D\uDD27'} Admin-Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="hub-login-admin-input"
|
||||||
|
placeholder="Passwort eingeben..."
|
||||||
|
value={adminPwd}
|
||||||
|
onChange={e => setAdminPwd(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdminSubmit()}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{adminError && <p className="hub-login-admin-error">{adminError}</p>}
|
||||||
|
<button
|
||||||
|
className="hub-login-admin-submit"
|
||||||
|
onClick={handleAdminSubmit}
|
||||||
|
disabled={loading || !adminPwd.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Prüfe...' : 'Einloggen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
web/src/UserSettings.tsx
Normal file
257
web/src/UserSettings.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
provider: 'discord' | 'steam';
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
globalName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SoundOption {
|
||||||
|
name: string;
|
||||||
|
fileName: string;
|
||||||
|
folder: string;
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSettingsProps {
|
||||||
|
user: UserInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserSettings({ user, onClose, onLogout }: UserSettingsProps) {
|
||||||
|
const [entranceSound, setEntranceSound] = useState<string | null>(null);
|
||||||
|
const [exitSound, setExitSound] = useState<string | null>(null);
|
||||||
|
const [availableSounds, setAvailableSounds] = useState<SoundOption[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState<'entrance' | 'exit' | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
const [activeSection, setActiveSection] = useState<'entrance' | 'exit'>('entrance');
|
||||||
|
|
||||||
|
// Fetch current sounds + available sounds
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/soundboard/user/sounds', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('/api/soundboard/user/available-sounds').then(r => r.json()),
|
||||||
|
])
|
||||||
|
.then(([userSounds, sounds]) => {
|
||||||
|
setEntranceSound(userSounds.entrance ?? null);
|
||||||
|
setExitSound(userSounds.exit ?? null);
|
||||||
|
setAvailableSounds(sounds);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMessage({ text: 'Fehler beim Laden der Einstellungen', type: 'error' });
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const showMessage = useCallback((text: string, type: 'success' | 'error') => {
|
||||||
|
setMessage({ text, type });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function setSound(type: 'entrance' | 'exit', fileName: string) {
|
||||||
|
setSaving(type);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/soundboard/user/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fileName }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (type === 'entrance') setEntranceSound(data.entrance);
|
||||||
|
else setExitSound(data.exit);
|
||||||
|
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound gesetzt!`, 'success');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
||||||
|
showMessage(err.error || 'Fehler', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showMessage('Verbindungsfehler', 'error');
|
||||||
|
}
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSound(type: 'entrance' | 'exit') {
|
||||||
|
setSaving(type);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/soundboard/user/${type}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
if (type === 'entrance') setEntranceSound(null);
|
||||||
|
else setExitSound(null);
|
||||||
|
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound entfernt`, 'success');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showMessage('Verbindungsfehler', 'error');
|
||||||
|
}
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group sounds by folder
|
||||||
|
const folders = new Map<string, SoundOption[]>();
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
for (const s of availableSounds) {
|
||||||
|
if (q && !s.name.toLowerCase().includes(q) && !s.fileName.toLowerCase().includes(q)) continue;
|
||||||
|
const key = s.folder || 'Allgemein';
|
||||||
|
if (!folders.has(key)) folders.set(key, []);
|
||||||
|
folders.get(key)!.push(s);
|
||||||
|
}
|
||||||
|
// Sort folders alphabetically, "Allgemein" first
|
||||||
|
const sortedFolders = [...folders.entries()].sort(([a], [b]) => {
|
||||||
|
if (a === 'Allgemein') return -1;
|
||||||
|
if (b === 'Allgemein') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSound = activeSection === 'entrance' ? entranceSound : exitSound;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hub-usettings-overlay" onClick={onClose}>
|
||||||
|
<div className="hub-usettings-panel" onClick={e => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="hub-usettings-header">
|
||||||
|
<div className="hub-usettings-user">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} alt="" className="hub-usettings-avatar" />
|
||||||
|
) : (
|
||||||
|
<div className="hub-usettings-avatar-placeholder">{user.username[0]?.toUpperCase()}</div>
|
||||||
|
)}
|
||||||
|
<div className="hub-usettings-user-info">
|
||||||
|
<span className="hub-usettings-username">{user.globalName || user.username}</span>
|
||||||
|
<span className="hub-usettings-discriminator">
|
||||||
|
{user.provider === 'steam' ? 'Steam' : `@${user.username}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hub-usettings-header-actions">
|
||||||
|
<button className="hub-usettings-logout" onClick={onLogout} title="Abmelden">
|
||||||
|
{'\uD83D\uDEAA'}
|
||||||
|
</button>
|
||||||
|
<button className="hub-usettings-close" onClick={onClose}>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message toast */}
|
||||||
|
{message && (
|
||||||
|
<div className={`hub-usettings-toast ${message.type}`}>
|
||||||
|
{message.type === 'success' ? '\u2705' : '\u274C'} {message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="hub-usettings-loading">
|
||||||
|
<span className="hub-update-spinner" /> Lade Einstellungen...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="hub-usettings-content">
|
||||||
|
{/* Section tabs */}
|
||||||
|
<div className="hub-usettings-tabs">
|
||||||
|
<button
|
||||||
|
className={`hub-usettings-tab ${activeSection === 'entrance' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSection('entrance')}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDC4B'} Entrance-Sound
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`hub-usettings-tab ${activeSection === 'exit' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSection('exit')}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDC4E'} Exit-Sound
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current sound display */}
|
||||||
|
<div className="hub-usettings-current">
|
||||||
|
<span className="hub-usettings-current-label">
|
||||||
|
Aktuell: {' '}
|
||||||
|
</span>
|
||||||
|
{currentSound ? (
|
||||||
|
<span className="hub-usettings-current-value">
|
||||||
|
{'\uD83C\uDFB5'} {currentSound}
|
||||||
|
<button
|
||||||
|
className="hub-usettings-remove-btn"
|
||||||
|
onClick={() => removeSound(activeSection)}
|
||||||
|
disabled={saving === activeSection}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="hub-usettings-current-none">Kein Sound gesetzt</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="hub-usettings-search-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="hub-usettings-search"
|
||||||
|
placeholder="Sounds durchsuchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button className="hub-usettings-search-clear" onClick={() => setSearch('')}>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sound list */}
|
||||||
|
<div className="hub-usettings-sounds">
|
||||||
|
{sortedFolders.length === 0 ? (
|
||||||
|
<div className="hub-usettings-empty">
|
||||||
|
{search ? 'Keine Treffer' : 'Keine Sounds verfügbar'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedFolders.map(([folder, sounds]) => (
|
||||||
|
<div key={folder} className="hub-usettings-folder">
|
||||||
|
<div className="hub-usettings-folder-name">{'\uD83D\uDCC1'} {folder}</div>
|
||||||
|
<div className="hub-usettings-folder-sounds">
|
||||||
|
{sounds.map(s => {
|
||||||
|
const isSelected = currentSound === s.relativePath || currentSound === s.fileName;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.relativePath}
|
||||||
|
className={`hub-usettings-sound-btn ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSound(activeSection, s.fileName)}
|
||||||
|
disabled={saving === activeSection}
|
||||||
|
title={s.relativePath}
|
||||||
|
>
|
||||||
|
<span className="hub-usettings-sound-icon">
|
||||||
|
{isSelected ? '\u2705' : '\uD83C\uDFB5'}
|
||||||
|
</span>
|
||||||
|
<span className="hub-usettings-sound-name">{s.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -89,7 +89,7 @@ function formatDate(iso: string): string {
|
||||||
COMPONENT
|
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">
|
|
||||||
⚙️
|
|
||||||
</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>⚙️ Game Library Admin</h3>
|
|
||||||
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="gl-admin-content">
|
|
||||||
<div className="gl-admin-toolbar">
|
|
||||||
<span className="gl-admin-status-text">✅ Eingeloggt als Admin</span>
|
|
||||||
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>↻ Aktualisieren</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{adminLoading ? (
|
|
||||||
<div className="gl-loading">Lade Profile...</div>
|
|
||||||
) : adminProfiles.length === 0 ? (
|
|
||||||
<p className="gl-search-results-title">Keine Profile vorhanden.</p>
|
|
||||||
) : (
|
|
||||||
<div className="gl-admin-list">
|
|
||||||
{adminProfiles.map((p: any) => (
|
|
||||||
<div key={p.id} className="gl-admin-item">
|
|
||||||
<img className="gl-admin-item-avatar" src={p.avatarUrl} alt={p.displayName} />
|
|
||||||
<div className="gl-admin-item-info">
|
|
||||||
<span className="gl-admin-item-name">{p.displayName}</span>
|
|
||||||
<span className="gl-admin-item-details">
|
|
||||||
{p.steamName && <span className="gl-platform-badge steam">Steam: {p.steamGames}</span>}
|
|
||||||
{p.gogName && <span className="gl-platform-badge gog">GOG: {p.gogGames}</span>}
|
|
||||||
<span className="gl-admin-item-total">{p.totalGames} Spiele</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="gl-admin-delete-btn"
|
|
||||||
onClick={() => adminDeleteProfile(p.id, p.displayName)}
|
|
||||||
title="Profil loeschen"
|
|
||||||
>
|
|
||||||
🗑️ Entfernen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── GOG Code Dialog (browser fallback only) ── */}
|
{/* ── GOG Code Dialog (browser fallback only) ── */}
|
||||||
{gogDialogOpen && (
|
{gogDialogOpen && (
|
||||||
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>
|
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ── */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
4027
web/src/styles.css
4027
web/src/styles.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue