From 8abe0775a5820bab86de7f48bf56293847f06397 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 02:12:02 +0100 Subject: [PATCH 01/10] feat: Discord-style glass morphism UI redesign + nightly CI/CD - App shell: gradient title, glass admin modal, avatar, admin login/logout - All plugin empty states: floating icon animations, updated typography - Soundboard: orange accent theme replacing blurple default - Global styles: glass morphism variables, Discord-dark color palette - CI/CD: nightly deploy (stops main, starts nightly on port 8085) + manual restore-main job Co-Authored-By: Claude Opus 4.6 --- .gitlab-ci.yml | 90 +++++++ web/src/App.tsx | 94 ++++++- web/src/plugins/game-library/game-library.css | 24 +- web/src/plugins/lolstats/lolstats.css | 24 +- web/src/plugins/soundboard/soundboard.css | 128 +++++++--- web/src/plugins/streaming/streaming.css | 25 +- .../plugins/watch-together/watch-together.css | 25 +- web/src/styles.css | 240 +++++++++++++++++- 8 files changed, 556 insertions(+), 94 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9000ddf..5bfaed3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -171,6 +171,96 @@ deploy: "$DEPLOY_IMAGE" - docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}" +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: stage: bump-version image: diff --git a/web/src/App.tsx b/web/src/App.tsx index eeed0b4..fb12660 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,6 +40,12 @@ export default function App() { const [showVersionModal, setShowVersionModal] = useState(false); const [pluginData, setPluginData] = useState>({}); + // Admin state + const [adminLoggedIn, setAdminLoggedIn] = useState(false); + const [showAdminModal, setShowAdminModal] = useState(false); + const [adminPassword, setAdminPassword] = useState(''); + const [adminError, setAdminError] = useState(''); + // Electron auto-update state const isElectron = !!(window as any).electronAPI?.isElectron; const electronVersion = isElectron ? (window as any).electronAPI.version : null; @@ -130,13 +136,43 @@ export default function App() { const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev'; - // Close version modal on Escape + // Close modals on Escape useEffect(() => { - if (!showVersionModal) return; - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); }; + if (!showVersionModal && !showAdminModal) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setShowVersionModal(false); + setShowAdminModal(false); + } + }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [showVersionModal]); + }, [showVersionModal, showAdminModal]); + + // Admin login handler + const handleAdminLogin = () => { + if (!adminPassword) return; + fetch('/api/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: adminPassword }), + }) + .then(r => { + if (r.ok) { + setAdminLoggedIn(true); + setAdminPassword(''); + setAdminError(''); + } else { + setAdminError('Falsches Passwort'); + } + }) + .catch(() => setAdminError('Verbindungsfehler')); + }; + + const handleAdminLogout = () => { + setAdminLoggedIn(false); + setShowAdminModal(false); + }; // Tab icon mapping @@ -198,7 +234,6 @@ export default function App() { { - // Status vom Main-Prozess synchronisieren bevor Modal öffnet if (isElectron) { const api = (window as any).electronAPI; const s = api.getUpdateStatus?.(); @@ -212,6 +247,15 @@ export default function App() { > v{version} + +
DK
@@ -307,6 +351,46 @@ export default function App() { )} + {showAdminModal && ( +
setShowAdminModal(false)}> +
e.stopPropagation()}> + {adminLoggedIn ? ( + <> +
Admin Panel
+
+
A
+
+ Administrator + Eingeloggt +
+
+ + + ) : ( + <> +
{'\u{1F511}'} Admin Login
+
Passwort eingeben um Einstellungen freizuschalten
+ {adminError &&
{adminError}
} + setAdminPassword(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAdminLogin(); }} + autoFocus + /> + + + )} +
+
+ )} +
{plugins.length === 0 ? (
diff --git a/web/src/plugins/game-library/game-library.css b/web/src/plugins/game-library/game-library.css index 372c8f0..2ed46b4 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -472,24 +472,30 @@ /* ── Empty state ── */ .gl-empty { - text-align: center; - padding: 60px 20px; + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 16px; + padding: 40px; height: 100%; } .gl-empty-icon { - font-size: 48px; - margin-bottom: 16px; + font-size: 64px; line-height: 1; + filter: drop-shadow(0 0 20px rgba(230,126,34,0.5)); + animation: gl-empty-float 3s ease-in-out infinite; +} + +@keyframes gl-empty-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } } .gl-empty h3 { - color: var(--text-normal); - margin: 0 0 8px; + font-size: 26px; font-weight: 700; color: #f2f3f5; + letter-spacing: -0.5px; margin: 0; } .gl-empty p { - color: var(--text-faint); - margin: 0; - font-size: 14px; + font-size: 15px; color: #80848e; + text-align: center; max-width: 360px; line-height: 1.5; margin: 0; } /* ── Common game playtime chips ── */ diff --git a/web/src/plugins/lolstats/lolstats.css b/web/src/plugins/lolstats/lolstats.css index 59503c4..f4b552d 100644 --- a/web/src/plugins/lolstats/lolstats.css +++ b/web/src/plugins/lolstats/lolstats.css @@ -456,22 +456,26 @@ } .lol-empty { - text-align: center; - padding: 60px 20px; - color: var(--text-faint); + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 16px; + padding: 40px; height: 100%; } .lol-empty-icon { - font-size: 48px; - margin-bottom: 12px; + font-size: 64px; line-height: 1; + filter: drop-shadow(0 0 20px rgba(230,126,34,0.5)); + animation: lol-float 3s ease-in-out infinite; +} +@keyframes lol-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } } .lol-empty h3 { - margin: 0 0 8px; - color: var(--text-muted); - font-size: 16px; + font-size: 26px; font-weight: 700; color: #f2f3f5; + letter-spacing: -0.5px; margin: 0; } .lol-empty p { - margin: 0; - font-size: 13px; + font-size: 15px; color: #80848e; + text-align: center; max-width: 360px; line-height: 1.5; margin: 0; } /* ── Load more ── */ diff --git a/web/src/plugins/soundboard/soundboard.css b/web/src/plugins/soundboard/soundboard.css index be47e1a..1c69e0a 100644 --- a/web/src/plugins/soundboard/soundboard.css +++ b/web/src/plugins/soundboard/soundboard.css @@ -3,7 +3,7 @@ /* Soundboard Plugin — ported from Jukebox styles */ /* ──────────────────────────────────────────── - Theme Variables — Default (Discord Blurple) + Theme Variables — Default (Orange Accent) ──────────────────────────────────────────── */ .sb-app { --bg-deep: #1a1b1e; @@ -18,10 +18,10 @@ --text-muted: #949ba4; --text-faint: #6d6f78; - --accent: #5865f2; - --accent-rgb: 88, 101, 242; - --accent-hover: #4752c4; - --accent-glow: rgba(88, 101, 242, .45); + --accent: #e67e22; + --accent-rgb: 230, 126, 34; + --accent-hover: #d35400; + --accent-glow: rgba(230, 126, 34, .45); --green: #23a55a; --red: #f23f42; @@ -91,6 +91,18 @@ --accent-glow: rgba(52, 152, 219, .4); } +/* ── Theme: Cherry ── */ +.sb-app[data-theme="cherry"] { + --bg-deep: #1a0f14; + --bg-primary: #221419; + --bg-secondary: #2f1c22; + --bg-tertiary: #3d242c; + --accent: #e74c3c; + --accent-rgb: 231, 76, 60; + --accent-hover: #c0392b; + --accent-glow: rgba(231, 76, 60, .4); +} + /* ──────────────────────────────────────────── App Layout ──────────────────────────────────────────── */ @@ -310,6 +322,33 @@ margin-left: 2px; } +/* ── Disconnect Button ── */ +.disconnect-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + background: rgba(242, 63, 66, .1); + border: 1px solid rgba(242, 63, 66, .2); + backdrop-filter: blur(8px); + color: var(--red); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); +} + +.disconnect-btn:hover { + background: rgba(242, 63, 66, .2); + border-color: rgba(242, 63, 66, .4); + box-shadow: 0 0 12px rgba(242, 63, 66, .15); +} + +.disconnect-btn:active { + transform: scale(0.97); +} + /* ── Connection Details Modal ── */ .conn-modal-overlay { position: fixed; @@ -340,9 +379,10 @@ align-items: center; gap: 8px; padding: 14px 16px; - border-bottom: 1px solid var(--border); - font-weight: 700; + border-bottom: 1px solid rgba(255,255,255,.06); font-size: 14px; + font-weight: 700; + color: var(--text-normal); } .conn-modal-close { margin-left: auto; @@ -438,7 +478,9 @@ gap: 6px; padding: 6px 14px; border-radius: 20px; - background: var(--bg-tertiary); + background: rgba(255, 255, 255, .05); + border: 1px solid rgba(255, 255, 255, .08); + backdrop-filter: blur(8px); color: var(--text-muted); font-family: var(--font); font-size: 13px; @@ -449,13 +491,16 @@ } .cat-tab:hover { - background: var(--bg-modifier-selected); + background: rgba(255, 255, 255, .1); + border-color: rgba(255, 255, 255, .15); color: var(--text-normal); } .cat-tab.active { background: var(--accent); color: var(--white); + border-color: var(--accent); + box-shadow: 0 2px 12px rgba(var(--accent-rgb), .35); } .tab-count { @@ -489,9 +534,9 @@ width: 100%; height: 32px; padding: 0 28px 0 32px; - border: 1px solid rgba(255, 255, 255, .06); + border: 1px solid rgba(255, 255, 255, .08); border-radius: 20px; - background: var(--bg-secondary); + background: var(--bg-deep); color: var(--text-normal); font-family: var(--font); font-size: 13px; @@ -572,8 +617,8 @@ height: 24px; padding: 0 10px; border-radius: 14px; - border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .45); - background: rgba(var(--accent-rgb, 88, 101, 242), .12); + border: 1px solid rgba(var(--accent-rgb), .45); + background: rgba(var(--accent-rgb), .12); color: var(--accent); font-size: 11px; font-weight: 700; @@ -639,7 +684,7 @@ } .tb-btn.random { - border-color: rgba(88, 101, 242, .3); + border-color: rgba(230, 126, 34, .3); color: var(--accent); } @@ -834,7 +879,7 @@ gap: 4px; padding: 3px 8px; border-radius: 999px; - background: rgba(var(--accent-rgb, 88, 101, 242), .15); + background: rgba(var(--accent-rgb), .15); color: var(--accent); font-size: 11px; font-weight: 600; @@ -875,8 +920,9 @@ font-size: 12px; font-weight: 600; color: var(--text-muted); - background: var(--bg-secondary); - border: 1px solid rgba(255, 255, 255, .06); + background: rgba(255, 255, 255, .05); + border: 1px solid rgba(255, 255, 255, .08); + backdrop-filter: blur(8px); white-space: nowrap; cursor: pointer; transition: all var(--transition); @@ -884,13 +930,16 @@ } .cat-chip:hover { - border-color: rgba(255, 255, 255, .12); + border-color: rgba(255, 255, 255, .15); color: var(--text-normal); - background: var(--bg-tertiary); + background: rgba(255, 255, 255, .1); } .cat-chip.active { - background: rgba(88, 101, 242, .1); + background: var(--accent); + color: var(--white); + border-color: var(--accent); + box-shadow: 0 2px 12px rgba(var(--accent-rgb), .35); } .cat-dot { @@ -924,7 +973,7 @@ } /* ──────────────────────────────────────────── - Sound Card + Sound Card — Glass Morphism ──────────────────────────────────────────── */ .sound-card { position: relative; @@ -934,11 +983,13 @@ justify-content: center; gap: 3px; padding: 12px 6px 8px; - background: var(--bg-secondary); + background: rgba(255, 255, 255, .05); + border: 1px solid rgba(255, 255, 255, .08); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); border-radius: var(--radius-lg); cursor: pointer; transition: all var(--transition); - border: 2px solid transparent; user-select: none; overflow: hidden; aspect-ratio: 1; @@ -953,15 +1004,14 @@ border-radius: inherit; opacity: 0; transition: opacity var(--transition); - background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%); + background: radial-gradient(circle at 50% 0%, rgba(var(--accent-rgb), .12), transparent 70%); pointer-events: none; } .sound-card:hover { - background: var(--bg-tertiary); - transform: translateY(-3px); - box-shadow: var(--shadow-med), 0 0 20px var(--accent-glow); - border-color: rgba(88, 101, 242, .2); + transform: scale(1.05); + border-color: rgba(var(--accent-rgb), .35); + box-shadow: 0 4px 20px rgba(var(--accent-rgb), .15); } .sound-card:hover::before { @@ -969,7 +1019,7 @@ } .sound-card:active { - transform: translateY(0); + transform: scale(0.97); transition-duration: 50ms; } @@ -992,7 +1042,7 @@ .ripple { position: absolute; border-radius: 50%; - background: rgba(88, 101, 242, .3); + background: rgba(var(--accent-rgb), .3); transform: scale(0); animation: ripple-expand 500ms ease-out forwards; pointer-events: none; @@ -1171,8 +1221,8 @@ gap: 6px; padding: 4px 12px; border-radius: 20px; - background: rgba(var(--accent-rgb, 88, 101, 242), .12); - border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2); + background: rgba(var(--accent-rgb), .12); + border: 1px solid rgba(var(--accent-rgb), .2); font-size: 12px; color: var(--text-muted); max-width: none; @@ -1251,23 +1301,26 @@ .vol-slider::-webkit-slider-thumb { -webkit-appearance: none; - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; background: var(--accent); + box-shadow: 0 0 8px rgba(var(--accent-rgb), .5); cursor: pointer; - transition: transform var(--transition); + transition: transform var(--transition), box-shadow var(--transition); } .vol-slider::-webkit-slider-thumb:hover { transform: scale(1.3); + box-shadow: 0 0 14px rgba(var(--accent-rgb), .7); } .vol-slider::-moz-range-thumb { - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; background: var(--accent); + box-shadow: 0 0 8px rgba(var(--accent-rgb), .5); border: none; cursor: pointer; } @@ -2042,7 +2095,6 @@ z-index: 300; animation: fade-in 150ms ease; } -@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } .dl-modal { width: 420px; max-width: 92vw; diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css index 9d43c2e..6f8cffa 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -356,23 +356,26 @@ /* ── Empty state ── */ .stream-empty { - text-align: center; - padding: 60px 20px; - color: var(--text-muted); + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 16px; + padding: 40px; height: 100%; } .stream-empty-icon { - font-size: 48px; - margin-bottom: 12px; - opacity: 0.4; + font-size: 64px; line-height: 1; + filter: drop-shadow(0 0 20px rgba(230,126,34,0.5)); + animation: stream-float 3s ease-in-out infinite; +} +@keyframes stream-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } } .stream-empty h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-normal); - margin-bottom: 6px; + font-size: 26px; font-weight: 700; color: #f2f3f5; + letter-spacing: -0.5px; margin: 0; } .stream-empty p { - font-size: 14px; + font-size: 15px; color: #80848e; + text-align: center; max-width: 360px; line-height: 1.5; margin: 0; } /* ── Error ── */ diff --git a/web/src/plugins/watch-together/watch-together.css b/web/src/plugins/watch-together/watch-together.css index 1873674..c24e283 100644 --- a/web/src/plugins/watch-together/watch-together.css +++ b/web/src/plugins/watch-together/watch-together.css @@ -161,23 +161,26 @@ /* ── Empty state ── */ .wt-empty { - text-align: center; - padding: 60px 20px; - color: var(--text-muted); + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 16px; + padding: 40px; height: 100%; } .wt-empty-icon { - font-size: 48px; - margin-bottom: 12px; - opacity: 0.4; + font-size: 64px; line-height: 1; + filter: drop-shadow(0 0 20px rgba(230,126,34,0.5)); + animation: wt-float 3s ease-in-out infinite; +} +@keyframes wt-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } } .wt-empty h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-normal); - margin-bottom: 6px; + font-size: 26px; font-weight: 700; color: #f2f3f5; + letter-spacing: -0.5px; margin: 0; } .wt-empty p { - font-size: 14px; + font-size: 15px; color: #80848e; + text-align: center; max-width: 360px; line-height: 1.5; margin: 0; } /* ── Error ── */ diff --git a/web/src/styles.css b/web/src/styles.css index 656b9d5..50e7604 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -4,19 +4,27 @@ --bg-primary: #1e1f22; --bg-secondary: #2b2d31; --bg-tertiary: #313338; - --text-normal: #dbdee1; - --text-muted: #949ba4; - --text-faint: #6d6f78; + --bg-light: #3a3c41; + --bg-lighter: #43464d; + --text-normal: #f2f3f5; + --text-muted: #b5bac1; + --text-faint: #80848e; --accent: #e67e22; --accent-rgb: 230, 126, 34; --accent-hover: #d35400; - --success: #57d28f; + --accent-glow: rgba(230, 126, 34, 0.5); + --accent-dim: rgba(230, 126, 34, 0.35); + --accent-subtle: rgba(230, 126, 34, 0.12); + --glass-bg: rgba(255, 255, 255, 0.05); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-bg-hover: rgba(255, 255, 255, 0.09); + --success: #23a559; --danger: #ed4245; --warning: #fee75c; --border: rgba(255, 255, 255, 0.06); --radius: 8px; --radius-lg: 12px; - --transition: 150ms ease; + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --font: 'Segoe UI', system-ui, -apple-system, sans-serif; --header-height: 56px; } @@ -78,14 +86,18 @@ html, body { .hub-logo { font-size: 24px; line-height: 1; + filter: drop-shadow(0 0 6px var(--accent-glow)); } .hub-title { font-size: 18px; font-weight: 700; - color: var(--text-normal); letter-spacing: -0.02em; white-space: nowrap; + background: linear-gradient(135deg, var(--accent), #f39c12); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } /* ── Connection Status Dot ── */ @@ -155,8 +167,8 @@ html, body { } .hub-tab.active { - color: var(--accent); - background: rgba(var(--accent-rgb), 0.1); + color: var(--text-normal); + background: var(--accent-subtle); } .hub-tab.active::after { @@ -165,10 +177,11 @@ html, body { bottom: -1px; left: 50%; transform: translateX(-50%); - width: calc(100% - 16px); + width: 70%; height: 2px; background: var(--accent); - border-radius: 1px; + border-radius: 2px; + box-shadow: 0 0 8px var(--accent-glow), 0 0 16px rgba(230,126,34,0.2); } .hub-tab-icon { @@ -616,6 +629,213 @@ html, body { color: var(--text-normal); } +/* ── Admin Button ── */ +.hub-admin-btn { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + color: var(--text-muted); + font-size: 16px; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + transition: all var(--transition); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.hub-admin-btn:hover { + background: var(--glass-bg-hover); + border-color: var(--accent-dim); + box-shadow: 0 0 12px rgba(230,126,34,0.15); +} + +.hub-admin-btn.logged-in { + border-color: var(--success); +} + +.hub-admin-green-dot { + position: absolute; + top: 1px; + right: 1px; + width: 8px; + height: 8px; + background: var(--success); + border-radius: 50%; + border: 2px solid var(--bg-primary); +} + +/* ── Avatar ── */ +.hub-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), #f39c12); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: #fff; + box-shadow: 0 0 0 2px var(--bg-primary); +} + +/* ── Admin Modal ── */ +.hub-admin-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: hub-modal-fade-in 0.2s ease; +} + +@keyframes hub-modal-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.hub-admin-modal { + background: var(--bg-secondary); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: 32px; + width: 360px; + max-width: 90vw; + box-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 0 40px rgba(230,126,34,0.08); + animation: hub-modal-slide-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes hub-modal-slide-in { + from { opacity: 0; transform: scale(0.95) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.hub-admin-modal-title { + font-size: 20px; + font-weight: 700; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 8px; +} + +.hub-admin-modal-subtitle { + font-size: 13px; + color: var(--text-faint); + margin-bottom: 24px; +} + +.hub-admin-modal-error { + font-size: 13px; + color: var(--danger); + margin-bottom: 12px; + padding: 8px 12px; + background: rgba(237, 66, 69, 0.1); + border-radius: var(--radius); +} + +.hub-admin-modal-input { + width: 100%; + background: var(--bg-deep); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; + padding: 10px 14px; + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); + margin-bottom: 16px; +} + +.hub-admin-modal-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(230,126,34,0.12); +} + +.hub-admin-modal-login { + width: 100%; + background: var(--accent); + border: none; + border-radius: var(--radius); + color: #fff; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + padding: 10px; + cursor: pointer; + transition: all var(--transition); + box-shadow: 0 2px 10px var(--accent-dim); +} + +.hub-admin-modal-login:hover { + background: var(--accent-hover); + box-shadow: 0 4px 16px var(--accent-glow); +} + +.hub-admin-modal-info { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.hub-admin-modal-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), #f39c12); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + font-weight: 700; + color: #fff; + box-shadow: 0 0 12px var(--accent-dim); +} + +.hub-admin-modal-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.hub-admin-modal-name { + font-weight: 600; + font-size: 15px; +} + +.hub-admin-modal-role { + font-size: 12px; + color: var(--success); + font-weight: 500; +} + +.hub-admin-modal-logout { + width: 100%; + background: rgba(237,66,69,0.12); + border: 1px solid rgba(237,66,69,0.25); + border-radius: var(--radius); + color: var(--danger); + font-family: var(--font); + font-size: 14px; + font-weight: 500; + padding: 10px; + cursor: pointer; + transition: all var(--transition); +} + +.hub-admin-modal-logout:hover { + background: rgba(237,66,69,0.2); +} + /* ── Focus Styles ── */ :focus-visible { outline: 2px solid var(--accent); From b3080fb763ee632fd99e64ea756e4b2757a08952 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 11:11:34 +0100 Subject: [PATCH 02/10] =?UTF-8?q?Refactor:=20Zentralisiertes=20Admin-Login?= =?UTF-8?q?=20f=C3=BCr=20alle=20Tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-Auth aus Soundboard-Plugin in core/auth.ts extrahiert. Ein Login-Button im Header gilt jetzt für die gesamte Webseite. Cookie-basiert (HMAC-SHA256, 7 Tage) — überlebt Page-Reload. Co-Authored-By: Claude Opus 4.6 --- server/src/core/auth.ts | 61 +++++++++++++++++++ server/src/core/middleware.ts | 20 +------ server/src/index.ts | 12 +++- server/src/plugins/soundboard/index.ts | 51 +--------------- web/src/App.tsx | 27 +++++++-- web/src/plugins/soundboard/SoundboardTab.tsx | 63 +------------------- 6 files changed, 101 insertions(+), 133 deletions(-) create mode 100644 server/src/core/auth.ts diff --git a/server/src/core/auth.ts b/server/src/core/auth.ts new file mode 100644 index 0000000..4373913 --- /dev/null +++ b/server/src/core/auth.ts @@ -0,0 +1,61 @@ +import crypto from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; + +const COOKIE_NAME = 'admin_token'; +const TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // 7 days + +type AdminPayload = { iat: number; exp: number }; + +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export function signAdminToken(adminPwd: string): string { + const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + TOKEN_TTL_MS }; + const body = b64url(JSON.stringify(payload)); + const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +export function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { + if (!token || !adminPwd) return false; + const [body, sig] = token.split('.'); + if (!body || !sig) return false; + const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + if (expected !== sig) return false; + try { + const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload; + return typeof payload.exp === 'number' && Date.now() < payload.exp; + } catch { return false; } +} + +export function readCookie(req: Request, key: string): string | undefined { + const c = req.headers.cookie; + if (!c) return undefined; + for (const part of c.split(';')) { + const [k, v] = part.trim().split('='); + if (k === key) return decodeURIComponent(v || ''); + } + return undefined; +} + +export function setAdminCookie(res: Response, token: string): void { + res.setHeader('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); +} + +export function clearAdminCookie(res: Response): void { + res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`); +} + +export function requireAdmin(adminPwd: string) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!adminPwd) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } + if (!verifyAdminToken(adminPwd, readCookie(req, COOKIE_NAME))) { + res.status(401).json({ error: 'Nicht eingeloggt' }); + return; + } + next(); + }; +} + +export { COOKIE_NAME }; diff --git a/server/src/core/middleware.ts b/server/src/core/middleware.ts index fc29a4b..7c5e7cd 100644 --- a/server/src/core/middleware.ts +++ b/server/src/core/middleware.ts @@ -1,24 +1,8 @@ import { Request, Response, NextFunction } from 'express'; import type { PluginContext } from './plugin.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(); - }; -} +// Re-export centralised admin auth +export { requireAdmin } from './auth.js'; /** * Guild filter middleware. diff --git a/server/src/index.ts b/server/src/index.ts index 6c322c2..9ccfa47 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { createClient } from './core/discord.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { loadState, getFullState, getStateDiag } from './core/persistence.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; +import { signAdminToken, verifyAdminToken, readCookie, setAdminCookie, clearAdminCookie, COOKIE_NAME } from './core/auth.js'; import radioPlugin from './plugins/radio/index.js'; import soundboardPlugin from './plugins/soundboard/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js'; @@ -93,16 +94,25 @@ app.get('/api/health', (_req, res) => { }); }); -// ── Admin Login ── +// ── Admin Auth (centralised) ── app.post('/api/admin/login', (req, res) => { if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } const { password } = req.body ?? {}; if (password === ADMIN_PWD) { + const token = signAdminToken(ADMIN_PWD); + setAdminCookie(res, token); res.json({ ok: true }); } else { 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 ── app.get('/api/plugins', (_req, res) => { diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index d53238e..777b195 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -17,6 +17,7 @@ import nacl from 'tweetnacl'; import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; +import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; // ── Config (env) ── const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; @@ -583,33 +584,6 @@ async function playFilePath(guildId: string, channelId: string, filePath: string if (relativeKey) incrementPlaysFor(relativeKey); } -// ── 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 ── function schedulePartyPlayback(guildId: string, channelId: string) { @@ -775,28 +749,7 @@ const soundboardPlugin: Plugin = { }, registerRoutes(app: express.Application, ctx: PluginContext) { - 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')) }); - }); + const requireAdmin = requireAdminFactory(ctx.adminPwd); // ── Sounds ── app.get('/api/soundboard/sounds', (req, res) => { diff --git a/web/src/App.tsx b/web/src/App.tsx index fb12660..e87ee2b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,7 @@ interface PluginInfo { } // Plugin tab components -const tabComponents: Record> = { +const tabComponents: Record> = { radio: RadioTab, soundboard: SoundboardTab, lolstats: LolstatsTab, @@ -22,7 +22,7 @@ const tabComponents: Record> = { 'game-library': GameLibraryTab, }; -export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { +export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) { tabComponents[pluginName] = component; } @@ -149,12 +149,21 @@ export default function App() { return () => window.removeEventListener('keydown', handler); }, [showVersionModal, showAdminModal]); + // Check admin status on mount (cookie-based, survives reload) + useEffect(() => { + fetch('/api/admin/status', { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(d => { if (d?.authenticated) setAdminLoggedIn(true); }) + .catch(() => {}); + }, []); + // Admin login handler const handleAdminLogin = () => { if (!adminPassword) return; fetch('/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ password: adminPassword }), }) .then(r => { @@ -162,6 +171,7 @@ export default function App() { setAdminLoggedIn(true); setAdminPassword(''); setAdminError(''); + setShowAdminModal(false); } else { setAdminError('Falsches Passwort'); } @@ -170,8 +180,15 @@ export default function App() { }; const handleAdminLogout = () => { - setAdminLoggedIn(false); - setShowAdminModal(false); + fetch('/api/admin/logout', { method: 'POST', credentials: 'include' }) + .then(() => { + setAdminLoggedIn(false); + setShowAdminModal(false); + }) + .catch(() => { + setAdminLoggedIn(false); + setShowAdminModal(false); + }); }; @@ -414,7 +431,7 @@ export default function App() { : { display: 'none' } } > - +
); }) diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index 064951b..dbe0103 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -186,24 +186,6 @@ async function apiGetVolume(guildId: string): Promise { return typeof data?.volume === 'number' ? data.volume : 1; } -async function apiAdminStatus(): Promise { - const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' }); - if (!res.ok) return false; - const data = await res.json(); - return !!data?.authenticated; -} - -async function apiAdminLogin(password: string): Promise { - const res = await fetch(`${API_BASE}/admin/login`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ password }) - }); - return res.ok; -} - -async function apiAdminLogout(): Promise { - await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' }); -} async function apiAdminDelete(paths: string[]): Promise { const res = await fetch(`${API_BASE}/admin/sounds/delete`, { @@ -324,13 +306,14 @@ interface VoiceStats { interface SoundboardTabProps { data: any; + isAdmin?: boolean; } /* ══════════════════════════════════════════════════════════════════ COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function SoundboardTab({ data }: SoundboardTabProps) { +export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: SoundboardTabProps) { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); @@ -378,9 +361,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { const volDebounceRef = useRef>(undefined); /* ── Admin ── */ - const [isAdmin, setIsAdmin] = useState(false); + const isAdmin = isAdminProp; const [showAdmin, setShowAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); const [adminSounds, setAdminSounds] = useState([]); const [adminLoading, setAdminLoading] = useState(false); const [adminQuery, setAdminQuery] = useState(''); @@ -521,7 +503,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); } } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } - try { setIsAdmin(await apiAdminStatus()); } catch { } try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -879,27 +860,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { } } - async function handleAdminLogin() { - try { - const ok = await apiAdminLogin(adminPwd); - if (ok) { - setIsAdmin(true); - setAdminPwd(''); - notify('Admin eingeloggt'); - } - else notify('Falsches Passwort', 'error'); - } catch { notify('Login fehlgeschlagen', 'error'); } - } - - async function handleAdminLogout() { - try { - await apiAdminLogout(); - setIsAdmin(false); - setAdminSelection({}); - cancelRename(); - notify('Ausgeloggt'); - } catch { } - } /* ── Computed ── */ const displaySounds = useMemo(() => { @@ -1447,21 +1407,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { close - {!isAdmin ? ( -
-
- - setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} - placeholder="Admin-Passwort..." - /> -
- -
- ) : (

Eingeloggt als Admin

@@ -1473,7 +1418,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { > Aktualisieren -
@@ -1585,7 +1529,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { )} - )} )} From f27093b87a5091b1b954473f2737c16ba0017d1c Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 11:22:07 +0100 Subject: [PATCH 03/10] Refactor: Admin-Login aus allen Plugins entfernt Duplizierte Auth-Logik aus Notifications, Game Library und Streaming Plugins komplett entfernt (-251 Zeilen). Alle Plugins nutzen jetzt die zentrale Auth aus core/auth.ts via isAdmin Prop. Admin-Buttons (Settings-Zahnrad) erscheinen nur noch wenn global eingeloggt. Kein separater Login pro Tab mehr noetig. Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/game-library/index.ts | 61 +--------------- server/src/plugins/notifications/index.ts | 64 +---------------- .../plugins/game-library/GameLibraryTab.tsx | 70 ++----------------- web/src/plugins/soundboard/SoundboardTab.tsx | 16 +++-- web/src/plugins/streaming/StreamingTab.tsx | 68 +++--------------- 5 files changed, 28 insertions(+), 251 deletions(-) diff --git a/server/src/plugins/game-library/index.ts b/server/src/plugins/game-library/index.ts index f48d0bd..461d787 100644 --- a/server/src/plugins/game-library/index.ts +++ b/server/src/plugins/game-library/index.ts @@ -1,6 +1,7 @@ import type express from 'express'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; +import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; @@ -58,34 +59,6 @@ const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166 // ── Admin auth helpers (same system as soundboard) ── -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 ── @@ -893,37 +866,7 @@ const gameLibraryPlugin: Plugin = { // Admin endpoints (same auth as soundboard) // ═══════════════════════════════════════════════════════════════════ - 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 }); - }); + const requireAdmin = requireAdminFactory(ctx.adminPwd); // ── GET /api/game-library/admin/profiles ── Alle Profile mit Details app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => { diff --git a/server/src/plugins/notifications/index.ts b/server/src/plugins/notifications/index.ts index 98afae8..f04c8c6 100644 --- a/server/src/plugins/notifications/index.ts +++ b/server/src/plugins/notifications/index.ts @@ -1,8 +1,8 @@ import type express from 'express'; -import crypto from 'node:crypto'; import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { getState, setState } from '../../core/persistence.js'; +import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; const NB = '[Notifications]'; @@ -26,40 +26,6 @@ let _client: Client | null = null; let _ctx: PluginContext | null = null; 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) ── @@ -159,33 +125,7 @@ const notificationsPlugin: Plugin = { }, registerRoutes(app, ctx) { - 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 }); - }); + const requireAdmin = requireAdminFactory(ctx.adminPwd); // List available text channels (requires admin) app.get('/api/notifications/channels', requireAdmin, async (_req, res) => { diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx index 2f30c4d..28fb090 100644 --- a/web/src/plugins/game-library/GameLibraryTab.tsx +++ b/web/src/plugins/game-library/GameLibraryTab.tsx @@ -89,7 +89,7 @@ function formatDate(iso: string): string { COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function GameLibraryTab({ data }: { data: any }) { +export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) { // ── State ── const [profiles, setProfiles] = useState([]); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); @@ -111,11 +111,9 @@ export default function GameLibraryTab({ data }: { data: any }) { // ── Admin state ── const [showAdmin, setShowAdmin] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); + const isAdmin = isAdminProp; const [adminProfiles, setAdminProfiles] = useState([]); const [adminLoading, setAdminLoading] = useState(false); - const [adminError, setAdminError] = useState(''); // ── SSE data sync ── useEffect(() => { @@ -133,42 +131,6 @@ export default function GameLibraryTab({ data }: { data: any }) { } catch { /* silent */ } }, []); - // ── Admin: check login status on mount ── - useEffect(() => { - fetch('/api/game-library/admin/status', { credentials: 'include' }) - .then(r => r.json()) - .then(d => setIsAdmin(d.admin === true)) - .catch(() => {}); - }, []); - - // ── Admin: login ── - const adminLogin = useCallback(async () => { - setAdminError(''); - try { - const resp = await fetch('/api/game-library/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), - credentials: 'include', - }); - if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - } else { - const d = await resp.json(); - setAdminError(d.error || 'Fehler'); - } - } catch { - setAdminError('Verbindung fehlgeschlagen'); - } - }, [adminPwd]); - - // ── Admin: logout ── - const adminLogout = useCallback(async () => { - await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); - setShowAdmin(false); - }, []); // ── Admin: load profiles ── const loadAdminProfiles = useCallback(async () => { @@ -552,9 +514,11 @@ export default function GameLibraryTab({ data }: { data: any }) { )}
- + {isAdmin && ( + + )}
{/* ── Profile Chips ── */} @@ -990,29 +954,10 @@ export default function GameLibraryTab({ data }: { data: any }) { - {!isAdmin ? ( -
-

Admin-Passwort eingeben:

-
- setAdminPwd(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }} - autoFocus - /> - -
- {adminError &&

{adminError}

} -
- ) : (
✅ Eingeloggt als Admin -
{adminLoading ? ( @@ -1044,7 +989,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
)} - )} )} diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index dbe0103..a1140b2 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -1000,13 +1000,15 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So )} )} - + {isAdmin && ( + + )} diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index cd85e68..46506db 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -56,7 +56,7 @@ const QUALITY_PRESETS = [ // ── Component ── -export default function StreamingTab({ data }: { data: any }) { +export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) { // ── State ── const [streams, setStreams] = useState([]); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); @@ -75,9 +75,7 @@ export default function StreamingTab({ data }: { data: any }) { // ── Admin / Notification Config ── const [showAdmin, setShowAdmin] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminError, setAdminError] = useState(''); + const isAdmin = isAdminProp; const [availableChannels, setAvailableChannels] = useState>([]); const [notifyConfig, setNotifyConfig] = useState>([]); const [configLoading, setConfigLoading] = useState(false); @@ -138,12 +136,8 @@ export default function StreamingTab({ data }: { data: any }) { return () => document.removeEventListener('click', handler); }, [openMenu]); - // Check admin status on mount + // Load notification bot status on mount useEffect(() => { - fetch('/api/notifications/admin/status', { credentials: 'include' }) - .then(r => r.json()) - .then(d => setIsAdmin(d.admin === true)) - .catch(() => {}); fetch('/api/notifications/status') .then(r => r.json()) .then(d => setNotifyStatus(d)) @@ -610,34 +604,6 @@ export default function StreamingTab({ data }: { data: any }) { setOpenMenu(null); }, [buildStreamLink]); - // ── Admin functions ── - const adminLogin = useCallback(async () => { - setAdminError(''); - try { - const resp = await fetch('/api/notifications/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), - credentials: 'include', - }); - if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - loadNotifyConfig(); - } else { - const d = await resp.json(); - setAdminError(d.error || 'Fehler'); - } - } catch { - setAdminError('Verbindung fehlgeschlagen'); - } - }, [adminPwd]); - - const adminLogout = useCallback(async () => { - await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); - setShowAdmin(false); - }, []); const loadNotifyConfig = useCallback(async () => { setConfigLoading(true); @@ -796,9 +762,11 @@ export default function StreamingTab({ data }: { data: any }) { {starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'} )} - + {isAdmin && ( + + )} {streams.length === 0 && !isBroadcasting ? ( @@ -912,24 +880,6 @@ export default function StreamingTab({ data }: { data: any }) { - {!isAdmin ? ( -
-

Admin-Passwort eingeben:

-
- setAdminPwd(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }} - autoFocus - /> - -
- {adminError &&

{adminError}

} -
- ) : (
@@ -937,7 +887,6 @@ export default function StreamingTab({ data }: { data: any }) { ? <>{'\u2705'} Bot online: {notifyStatus.botTag} : <>{'\u26A0\uFE0F'} Bot offline — DISCORD_TOKEN_NOTIFICATIONS setzen} -
{configLoading ? ( @@ -993,7 +942,6 @@ export default function StreamingTab({ data }: { data: any }) { )}
- )} )} From 10fcde125d028a0c80023eb8e7fed6944c2db16b Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 11:42:48 +0100 Subject: [PATCH 04/10] Streaming: 30fps Presets entfernt, Dropdown breiter Qualitaetsstufen: 720p60, 1080p60, 2K60, 4K60, 4K165 Ultra. Dropdown von 120px auf 160px verbreitert damit Text nicht abgeschnitten wird. Co-Authored-By: Claude Opus 4.6 --- web/src/plugins/streaming/StreamingTab.tsx | 13 ++++++------- web/src/plugins/streaming/streaming.css | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index 46506db..4ac264a 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -46,11 +46,10 @@ function formatElapsed(startedAt: string): string { // ── Quality Presets ── const QUALITY_PRESETS = [ - { label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 }, - { label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 }, - { label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 }, - { label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 }, - { label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 }, + { label: '720p60', width: 1280, height: 720, fps: 60, bitrate: 4_000_000 }, + { label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 }, + { label: '2K60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 }, + { label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 }, { label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 }, ] as const; @@ -62,7 +61,7 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); const [streamTitle, setStreamTitle] = useState('Screen Share'); const [streamPassword, setStreamPassword] = useState(''); - const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60 + const [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60 const [error, setError] = useState(null); const [joinModal, setJoinModal] = useState(null); const [myStreamId, setMyStreamId] = useState(null); @@ -98,7 +97,7 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d // Refs that mirror state (avoid stale closures in WS handler) const isBroadcastingRef = useRef(false); const viewingRef = useRef(null); - const qualityRef = useRef(QUALITY_PRESETS[2]); + const qualityRef = useRef(QUALITY_PRESETS[1]); useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]); useEffect(() => { viewingRef.current = viewing; }, [viewing]); useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]); diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css index 6f8cffa..8f57bdd 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -412,7 +412,7 @@ } .stream-select-quality { - width: 120px; + width: 160px; padding: 10px 14px; border: 1px solid var(--bg-tertiary); border-radius: var(--radius); From 3127d31355ecfaa27cb83598d17ae29f532897b1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 11:46:20 +0100 Subject: [PATCH 05/10] Streaming: Presets zeigen jetzt Bitrate statt Aufloesung Aufloesung ist immer nativ (Monitor des Broadcasters), die Presets steuern nur Bitrate und FPS. Labels entsprechend angepasst: Niedrig (4 Mbit) bis Max (50 Mbit/165Hz). Dropdown auf 200px. Co-Authored-By: Claude Opus 4.6 --- web/src/plugins/streaming/StreamingTab.tsx | 12 ++++++------ web/src/plugins/streaming/streaming.css | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index 4ac264a..d23a342 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -46,11 +46,11 @@ function formatElapsed(startedAt: string): string { // ── Quality Presets ── const QUALITY_PRESETS = [ - { label: '720p60', width: 1280, height: 720, fps: 60, bitrate: 4_000_000 }, - { label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 }, - { label: '2K60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 }, - { label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 }, - { label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 }, + { label: 'Niedrig (4 Mbit)', fps: 60, bitrate: 4_000_000 }, + { label: 'Mittel (8 Mbit)', fps: 60, bitrate: 8_000_000 }, + { label: 'Hoch (14 Mbit)', fps: 60, bitrate: 14_000_000 }, + { label: 'Ultra (25 Mbit)', fps: 60, bitrate: 25_000_000 }, + { label: 'Max (50 Mbit/165Hz)', fps: 165, bitrate: 50_000_000 }, ] as const; // ── Component ── @@ -415,7 +415,7 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d try { const q = qualityRef.current; const stream = await navigator.mediaDevices.getDisplayMedia({ - video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } }, + video: { frameRate: { ideal: q.fps } }, audio: true, }); localStreamRef.current = stream; diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css index 8f57bdd..319bdae 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -412,7 +412,7 @@ } .stream-select-quality { - width: 160px; + width: 200px; padding: 10px 14px; border: 1px solid var(--bg-tertiary); border-radius: var(--radius); From 041557c8858a2913920f3870602e7f5578b396ef Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 11:52:42 +0100 Subject: [PATCH 06/10] UI: Avatar entfernt, Streaming-Topbar mit Labels - DK-Avatar aus Header entfernt (kein Zweck) - Streaming-Felder haben jetzt Ueberschriften: Name, Titel, Passwort, Qualitaet - Passwort-Feld von 140px auf 180px verbreitert - Topbar aligned an Feldunterkante (flex-end) Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 1 - web/src/plugins/streaming/StreamingTab.tsx | 77 ++++++++++++---------- web/src/plugins/streaming/streaming.css | 24 ++++++- 3 files changed, 65 insertions(+), 37 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index e87ee2b..9c2f9ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -272,7 +272,6 @@ export default function App() { {'\u{1F511}'} {adminLoggedIn && } -
DK
diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index d23a342..b34762f 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -719,39 +719,50 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d )}
- setUserName(e.target.value)} - disabled={isBroadcasting} - /> - setStreamTitle(e.target.value)} - disabled={isBroadcasting} - /> - setStreamPassword(e.target.value)} - disabled={isBroadcasting} - /> - + + + + {isBroadcasting ? ( ))} -
- {!(window as any).electronAPI && ( - - {'\u2B07\uFE0F'} - Desktop App - - )} + {/* Accent Theme Picker */} +
+ {accentSwatches.map(swatch => ( +
+ + {/* Sidebar Footer: User + Connection + Settings + Admin */} +
+
+ D + {connected && } +
+
+ User + + {connected ? 'Verbunden' : 'Getrennt'} + +
- { if (isElectron) { const api = (window as any).electronAPI; @@ -260,22 +321,50 @@ export default function App() { } setShowVersionModal(true); }} - title="Versionsinformationen" + title="Einstellungen & Version" > - v{version} - -
- + + {/* ===== MAIN CONTENT ===== */} +
+
+ {plugins.length === 0 ? ( +
+ {'\u{1F4E6}'} +

Keine Plugins geladen

+

Plugins werden im Server konfiguriert.

+
+ ) : ( + /* 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 ( +
+ +
+ ); + }) + )} +
+
+ {/* ===== VERSION MODAL ===== */} {showVersionModal && (
setShowVersionModal(false)}>
e.stopPropagation()}> @@ -321,13 +410,13 @@ export default function App() { {updateStatus === 'checking' && (
- Suche nach Updates… + Suche nach Updates...
)} {updateStatus === 'downloading' && (
- Update wird heruntergeladen… + Update wird heruntergeladen...
)} {updateStatus === 'ready' && ( @@ -367,6 +456,7 @@ export default function App() {
)} + {/* ===== ADMIN MODAL ===== */} {showAdminModal && (
setShowAdminModal(false)}>
e.stopPropagation()}> @@ -406,36 +496,6 @@ export default function App() {
)} - -
- {plugins.length === 0 ? ( -
- {'\u{1F4E6}'} -

Keine Plugins geladen

-

Plugins werden im Server konfiguriert.

-
- ) : ( - /* 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 ( -
- -
- ); - }) - )} -
); } diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index a1140b2..434f0f2 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -267,14 +267,6 @@ function apiUploadFileWithName( 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 = [ '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', @@ -508,7 +500,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - /* ── Theme (persist only, data-theme is set on .sb-app div) ── */ + /* ── Theme (persist — global theming now handled by app-shell) ── */ useEffect(() => { localStorage.setItem('jb-theme', theme); }, [theme]); @@ -928,78 +920,60 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So RENDER ════════════════════════════════════════════ */ return ( -
+
{chaosMode &&
} - {/* ═══ TOPBAR ═══ */} -
-
-
- music_note -
- Soundboard + {/* ═══ CONTENT HEADER ═══ */} +
+
+ Soundboard + {totalSoundsDisplay} +
- {/* Channel Dropdown */} -
e.stopPropagation()}> - - {channelOpen && ( -
- {Object.entries(channelsByGuild).map(([guild, chs]) => ( - -
{guild}
- {chs.map(ch => ( -
handleChannelSelect(ch)} - > - volume_up - {ch.channelName}{ch.members ? ` (${ch.members})` : ''} -
- ))} -
- ))} - {channels.length === 0 && ( -
- Keine Channels verfuegbar -
- )} -
- )} -
+ )}
-
-
{clockMain}{clockSec}
-
- -
+
+ {/* Now Playing indicator */} {lastPlayed && (
- Last Played: {lastPlayed} + Now: {lastPlayed}
)} + + {/* Connection status */} {selected && ( -
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> - +
setShowConnModal(true)} + style={{ cursor: 'pointer' }} + title="Verbindungsdetails" + > + Verbunden {voiceStats?.voicePing != null && ( - {voiceStats.voicePing}ms + {voiceStats.voicePing}ms )}
)} + + {/* Admin button */} {isAdmin && ( )} + + {/* Playback controls */} +
+ + + +
-
+
{/* ═══ TOOLBAR ═══ */}
-
- - - -
+ {/* Filter tabs */} + + + -
- search - setQuery(e.target.value)} - /> - {query && ( - - )} -
+
+ {/* URL import */}
{getUrlType(importUrl) === 'youtube' ? 'smart_display' @@ -1085,113 +1065,120 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
-
- -
- { - const newVol = volume > 0 ? 0 : 0.5; - setVolume(newVol); - if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {}); - }} - > - {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} - - { - 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} - /> - {Math.round(volume * 100)}% -
- - - - - - - -
- grid_view - setCardSize(parseInt(e.target.value))} - /> -
- -
- {THEMES.map(t => ( -
setTheme(t.id)} +
+ {/* Volume */} +
+ { + 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'} + + { + 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} /> - ))} -
-
- -
-
- library_music -
- Sounds gesamt - {totalSoundsDisplay} + {Math.round(volume * 100)}%
-
-
- leaderboard -
- Most Played -
- {analyticsTop.length === 0 ? ( - Noch keine Plays - ) : ( - analyticsTop.map((item, idx) => ( - - {idx + 1}. {item.name} ({item.count}) - - )) - )} -
+ {/* Channel selector */} +
e.stopPropagation()}> + + {channelOpen && ( +
+ {Object.entries(channelsByGuild).map(([guild, chs]) => ( + +
{guild}
+ {chs.map(ch => ( +
handleChannelSelect(ch)} + > + volume_up + {ch.channelName}{ch.members ? ` (${ch.members})` : ''} +
+ ))} +
+ ))} + {channels.length === 0 && ( +
+ Keine Channels verfuegbar +
+ )} +
+ )} +
+ + {/* Card size slider */} +
+ grid_view + setCardSize(parseInt(e.target.value))} + />
+ {/* ═══ MOST PLAYED / ANALYTICS ═══ */} + {analyticsTop.length > 0 && ( +
+
+ leaderboard + Most Played +
+
+ {analyticsTop.map((item, idx) => ( +
{ + const found = sounds.find(s => (s.relativePath ?? s.fileName) === item.relativePath); + if (found) handlePlay(found); + }} + > + {idx + 1} + {item.name} + {item.count} +
+ ))} +
+
+ )} + {/* ═══ FOLDER CHIPS ═══ */} {activeTab === 'all' && visibleFolders.length > 0 && (
@@ -1214,8 +1201,8 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
)} - {/* ═══ MAIN ═══ */} -
+ {/* ═══ SOUND GRID ═══ */} +
{displaySounds.length === 0 ? (
{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}
@@ -1232,66 +1219,88 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So : 'Hier gibt\'s noch nichts zu hoeren.'}
- ) : ( -
- {displaySounds.map((s, idx) => { - const key = s.relativePath ?? s.fileName; - const isFav = !!favs[key]; - const isPlaying = lastPlayed === s.name; - const isNew = s.isRecent || s.badges?.includes('new'); - const initial = s.name.charAt(0).toUpperCase(); - const showInitial = firstOfInitial.has(idx); - const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; + ) : (() => { + // Group sounds by initial letter for category headers + const groups: { letter: string; sounds: { sound: Sound; globalIdx: number }[] }[] = []; + let currentLetter = ''; + displaySounds.forEach((s, idx) => { + const ch = s.name.charAt(0).toUpperCase(); + const letter = /[A-Z]/.test(ch) ? ch : '#'; + if (letter !== currentLetter) { + currentLetter = letter; + groups.push({ letter, sounds: [] }); + } + groups[groups.length - 1].sounds.push({ sound: s, globalIdx: idx }); + }); - return ( -
{ - const card = e.currentTarget; - const rect = card.getBoundingClientRect(); - const ripple = document.createElement('div'); - ripple.className = 'ripple'; - const sz = Math.max(rect.width, rect.height); - ripple.style.width = ripple.style.height = sz + 'px'; - ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; - ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; - card.appendChild(ripple); - setTimeout(() => ripple.remove(), 500); - handlePlay(s); - }} - onContextMenu={e => { - e.preventDefault(); - e.stopPropagation(); - setCtxMenu({ - x: Math.min(e.clientX, window.innerWidth - 170), - y: Math.min(e.clientY, window.innerHeight - 140), - sound: s, - }); - }} - title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} - > - {isNew && NEU} - { e.stopPropagation(); toggleFav(key); }} - > - {isFav ? 'star' : 'star_border'} - - {showInitial && {initial}} - {s.name} - {s.folder && {s.folder}} -
-
-
-
-
- ); - })} -
- )} -
+ return groups.map(group => ( + +
+ {group.letter} + {group.sounds.length} Sound{group.sounds.length !== 1 ? 's' : ''} + +
+
+ {group.sounds.map(({ sound: s, globalIdx: idx }) => { + const key = s.relativePath ?? s.fileName; + const isFav = !!favs[key]; + const isPlaying = lastPlayed === s.name; + const isNew = s.isRecent || s.badges?.includes('new'); + const initial = s.name.charAt(0).toUpperCase(); + const showInitial = firstOfInitial.has(idx); + const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; + + return ( +
{ + const card = e.currentTarget; + const rect = card.getBoundingClientRect(); + const ripple = document.createElement('div'); + ripple.className = 'ripple'; + const sz = Math.max(rect.width, rect.height); + ripple.style.width = ripple.style.height = sz + 'px'; + ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; + ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; + card.appendChild(ripple); + setTimeout(() => ripple.remove(), 500); + handlePlay(s); + }} + onContextMenu={e => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ + x: Math.min(e.clientX, window.innerWidth - 170), + y: Math.min(e.clientY, window.innerHeight - 140), + sound: s, + }); + }} + title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} + > + {isNew && NEU} + { e.stopPropagation(); toggleFav(key); }} + > + {isFav ? 'star' : 'star_border'} + + {showInitial && {initial}} + {s.name} + {s.folder && {s.folder}} +
+
+
+
+
+ ); + })} +
+ + )); + })()} +
{/* ═══ CONTEXT MENU ═══ */} {ctxMenu && ( @@ -1658,7 +1667,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So {dropPhase === 'naming' && (