diff --git a/.forgejo/workflows/build-deploy.yml b/.forgejo/workflows/build-deploy.yml deleted file mode 100644 index 9cd5b41..0000000 --- a/.forgejo/workflows/build-deploy.yml +++ /dev/null @@ -1,153 +0,0 @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1fa7956..efd5156 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -173,6 +173,96 @@ deploy: - echo "[Deploy] Cleaning up dangling images..." - 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: stage: bump-version image: diff --git a/VERSION b/VERSION index 6476b3a..53adb84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.18 +1.8.2 diff --git a/electron/main.js b/electron/main.js index ca46006..21f33fb 100644 --- a/electron/main.js +++ b/electron/main.js @@ -134,7 +134,7 @@ function createWindow() { session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } }); if (sources.length === 0) { - callback(); + callback({}); return; } @@ -155,31 +155,16 @@ h2{font-size:16px;margin-bottom:12px;color:#ccc} .item:hover{border-color:#7c5cff;transform:scale(1.03)} .item img{width:100%;height:120px;object-fit:cover;display:block;background:#111} .item .label{padding:8px 10px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.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-row{text-align:center;margin-top: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} -

Bildschirm oder Fenster wählen

+

Bildschirm oder Fenster w\\u00e4hlen

-
- - -
+
- + +
diff --git a/web/src/AdminPanel.tsx b/web/src/AdminPanel.tsx deleted file mode 100644 index 020babd..0000000 --- a/web/src/AdminPanel.tsx +++ /dev/null @@ -1,664 +0,0 @@ -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 { - 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 { - 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 { - 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 { - 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('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([]); - const [sbLoading, setSbLoading] = useState(false); - const [sbQuery, setSbQuery] = useState(''); - const [sbSelection, setSbSelection] = useState>({}); - const [sbRenameTarget, setSbRenameTarget] = useState(''); - const [sbRenameValue, setSbRenameValue] = useState(''); - const [sbUploadProgress, setSbUploadProgress] = useState(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>([]); - const [stNotifyConfig, setStNotifyConfig] = useState>([]); - 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([]); - 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 ( -
{ if (e.target === e.currentTarget) onClose(); }}> -
- {/* ── Sidebar ── */} -
-
{'\u2699\uFE0F'} Admin
- - -
- - {/* ── Content ── */} -
-
-

- {tabs.find(t => t.id === activeTab)?.icon}{' '} - {tabs.find(t => t.id === activeTab)?.label} -

- -
- -
- {/* ═══════════════════ SOUNDBOARD TAB ═══════════════════ */} - {activeTab === 'soundboard' && ( -
-
- setSbQuery(e.target.value)} - placeholder="Nach Name, Ordner oder Pfad filtern..." - /> - -
- - {/* Upload */} - - - {/* Bulk actions */} -
- - -
- - {/* Sound list */} -
- {sbLoading ? ( -
Lade Sounds...
- ) : sbFiltered.length === 0 ? ( -
Keine Sounds gefunden.
- ) : ( -
- {sbFiltered.map(sound => { - const key = soundKey(sound); - const editing = sbRenameTarget === key; - return ( -
- -
-
{sound.name}
-
- {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} - {' \u00B7 '} - {key} -
- {editing && ( -
- setSbRenameValue(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void sbSubmitRename(); - if (e.key === 'Escape') sbCancelRename(); - }} - placeholder="Neuer Name..." - autoFocus - /> - - -
- )} -
- {!editing && ( -
- - -
- )} -
- ); - })} -
- )} -
-
- )} - - {/* ═══════════════════ STREAMING TAB ═══════════════════ */} - {activeTab === 'streaming' && ( -
-
- - - {stNotifyStatus.online - ? <>Bot online: {stNotifyStatus.botTag} - : <>Bot offline} - - -
- - {stConfigLoading ? ( -
Lade Kanaele...
- ) : stAvailableChannels.length === 0 ? ( -
- {stNotifyStatus.online - ? 'Keine Text-Kanaele gefunden. Bot hat moeglicherweise keinen Zugriff.' - : 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'} -
- ) : ( - <> -

- Waehle die Kanaele, in die Benachrichtigungen gesendet werden sollen: -

-
- {stAvailableChannels.map(ch => ( -
-
- #{ch.channelName} - {ch.guildName} -
-
- - -
-
- ))} -
-
- -
- - )} -
- )} - - {/* ═══════════════════ GAME LIBRARY TAB ═══════════════════ */} - {activeTab === 'game-library' && ( -
-
- - - Eingeloggt als Admin - - -
- - {glLoading ? ( -
Lade Profile...
- ) : glProfiles.length === 0 ? ( -
Keine Profile vorhanden.
- ) : ( -
- {glProfiles.map((p: any) => ( -
- {p.displayName} -
- {p.displayName} - - {p.steamName && Steam: {p.steamGames}} - {p.gogName && GOG: {p.gogGames}} - {p.totalGames} Spiele - -
- -
- ))} -
- )} -
- )} -
-
- - {/* ── Toast ── */} - {toast && ( -
- {toast.type === 'error' ? '\u274C' : '\u2705'} {toast.msg} -
- )} -
-
- ); -} diff --git a/web/src/App.tsx b/web/src/App.tsx index 2347765..a1dc90c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,9 +5,6 @@ import LolstatsTab from './plugins/lolstats/LolstatsTab'; import StreamingTab from './plugins/streaming/StreamingTab'; import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab'; import GameLibraryTab from './plugins/game-library/GameLibraryTab'; -import AdminPanel from './AdminPanel'; -import LoginModal from './LoginModal'; -import UserSettings from './UserSettings'; interface PluginInfo { name: string; @@ -15,23 +12,6 @@ interface PluginInfo { description: string; } -interface AuthUser { - authenticated: boolean; - provider?: 'discord' | 'steam' | 'admin'; - discordId?: string; - steamId?: string; - username?: string; - avatar?: string | null; - globalName?: string | null; - isAdmin?: boolean; -} - -interface AuthProviders { - discord: boolean; - steam: boolean; - admin: boolean; -} - // Plugin tab components const tabComponents: Record> = { radio: RadioTab, @@ -60,18 +40,20 @@ export default function App() { const [showVersionModal, setShowVersionModal] = useState(false); const [pluginData, setPluginData] = useState>({}); - // ── Unified Auth State ── - const [user, setUser] = useState({ authenticated: false }); - const [providers, setProviders] = useState({ discord: false, steam: false, admin: false }); - const [showLoginModal, setShowLoginModal] = useState(false); - const [showUserSettings, setShowUserSettings] = useState(false); - const [showAdminPanel, setShowAdminPanel] = useState(false); + // Admin state + const [adminLoggedIn, setAdminLoggedIn] = useState(false); + const [showAdminModal, setShowAdminModal] = useState(false); + const [adminPassword, setAdminPassword] = useState(''); + const [adminError, setAdminError] = useState(''); - // Derived state - const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true); - const isDiscordUser = user.authenticated && user.provider === 'discord'; - const isSteamUser = user.authenticated && user.provider === 'steam'; - const isRegularUser = isDiscordUser || isSteamUser; + // Accent theme state + const [accentTheme, setAccentTheme] = useState(() => { + return localStorage.getItem('gaming-hub-accent') || 'ember'; + }); + + useEffect(() => { + localStorage.setItem('gaming-hub-accent', accentTheme); + }, [accentTheme]); // Electron auto-update state const isElectron = !!(window as any).electronAPI?.isElectron; @@ -87,56 +69,6 @@ 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 { - try { - const resp = await fetch('/api/auth/admin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), - credentials: 'include', - }); - if (resp.ok) { - setUser({ authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true }); - return true; - } - return false; - } catch { - return false; - } - } - - // Unified logout - async function handleLogout() { - await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); - setUser({ authenticated: false }); - setShowUserSettings(false); - setShowAdminPanel(false); - } - // Electron auto-update listeners useEffect(() => { if (!isElectron) return; @@ -213,13 +145,60 @@ 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]); + + // 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 @@ -236,91 +215,103 @@ export default function App() { 'game-library': '\u{1F3AE}', }; - // What happens when the user button is clicked - function handleUserButtonClick() { - if (!user.authenticated) { - setShowLoginModal(true); - } else if (isAdmin) { - setShowAdminPanel(true); - } else if (isRegularUser) { - setShowUserSettings(true); - } - } + // Accent swatches configuration + const accentSwatches: { name: string; color: string }[] = [ + { name: 'ember', color: '#e67e22' }, + { name: 'amethyst', color: '#8e44ad' }, + { name: 'ocean', color: '#2e86c1' }, + { name: 'jade', color: '#27ae60' }, + { name: 'rose', color: '#e74c8b' }, + { name: 'crimson', color: '#d63031' }, + ]; + + // Find active plugin for display + const activePlugin = plugins.find(p => p.name === activeTab); return ( -
-
-
- {'\u{1F3AE}'} - Gaming Hub - +
+ {/* ===== SIDEBAR ===== */} +
+ + {/* ===== 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()}> @@ -383,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' && ( @@ -429,64 +456,46 @@ export default function App() {
)} - {/* Login Modal */} - {showLoginModal && ( - setShowLoginModal(false)} - onAdminLogin={handleAdminLogin} - providers={providers} - /> - )} - - {/* User Settings (Discord + Steam users) */} - {showUserSettings && isRegularUser && ( - setShowUserSettings(false)} - onLogout={handleLogout} - /> - )} - - {/* Admin Panel */} - {showAdminPanel && isAdmin && ( - setShowAdminPanel(false)} onLogout={() => { handleLogout(); setShowAdminPanel(false); }} /> - )} - -
- {plugins.length === 0 ? ( -
- {'\u{1F4E6}'} -

Keine Plugins geladen

-

Plugins werden im Server konfiguriert.

+ {/* ===== ADMIN MODAL ===== */} + {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 + /> + + + )}
- ) : ( - /* 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/LoginModal.tsx b/web/src/LoginModal.tsx deleted file mode 100644 index 1840ed8..0000000 --- a/web/src/LoginModal.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useState, useEffect } from 'react'; - -interface LoginModalProps { - onClose: () => void; - onAdminLogin: (password: string) => Promise; - 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 ( -
-
e.stopPropagation()}> -
- {'\uD83D\uDD10'} Anmelden - -
- - {!showAdminForm ? ( -
-

Melde dich an, um deine Einstellungen zu verwalten.

- -
- {/* Discord */} - {providers.discord && ( - - - - - Mit Discord anmelden - - )} - - {/* Steam */} - {providers.steam && ( - - - - - Mit Steam anmelden - - )} - - {/* Admin */} - {providers.admin && ( - - )} -
- - {!providers.discord && ( -

- {'\u2139\uFE0F'} Discord Login ist nicht konfiguriert. Der Server braucht DISCORD_CLIENT_ID und DISCORD_CLIENT_SECRET. -

- )} -
- ) : ( -
- -
- - setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminSubmit()} - autoFocus - disabled={loading} - /> - {adminError &&

{adminError}

} - -
-
- )} -
-
- ); -} diff --git a/web/src/UserSettings.tsx b/web/src/UserSettings.tsx deleted file mode 100644 index cb7ce65..0000000 --- a/web/src/UserSettings.tsx +++ /dev/null @@ -1,257 +0,0 @@ -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(null); - const [exitSound, setExitSound] = useState(null); - const [availableSounds, setAvailableSounds] = useState([]); - 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(); - 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 ( -
-
e.stopPropagation()}> - {/* Header */} -
-
- {user.avatar ? ( - - ) : ( -
{user.username[0]?.toUpperCase()}
- )} -
- {user.globalName || user.username} - - {user.provider === 'steam' ? 'Steam' : `@${user.username}`} - -
-
-
- - -
-
- - {/* Message toast */} - {message && ( -
- {message.type === 'success' ? '\u2705' : '\u274C'} {message.text} -
- )} - - {loading ? ( -
- Lade Einstellungen... -
- ) : ( -
- {/* Section tabs */} -
- - -
- - {/* Current sound display */} -
- - Aktuell: {' '} - - {currentSound ? ( - - {'\uD83C\uDFB5'} {currentSound} - - - ) : ( - Kein Sound gesetzt - )} -
- - {/* Search */} -
- setSearch(e.target.value)} - /> - {search && ( - - )} -
- - {/* Sound list */} -
- {sortedFolders.length === 0 ? ( -
- {search ? 'Keine Treffer' : 'Keine Sounds verfügbar'} -
- ) : ( - sortedFolders.map(([folder, sounds]) => ( -
-
{'\uD83D\uDCC1'} {folder}
-
- {sounds.map(s => { - const isSelected = currentSound === s.relativePath || currentSound === s.fileName; - return ( - - ); - })} -
-
- )) - )} -
-
- )} -
-
- ); -} diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx index 3072cc6..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, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) { +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'); @@ -109,9 +109,11 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: a const filterInputRef = useRef(null); const [filterQuery, setFilterQuery] = useState(''); - // ── Admin (centralized in App.tsx) ── - const _isAdmin = isAdminProp ?? false; - void _isAdmin; + // ── Admin state ── + const [showAdmin, setShowAdmin] = useState(false); + const isAdmin = isAdminProp; + const [adminProfiles, setAdminProfiles] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); // ── SSE data sync ── useEffect(() => { @@ -130,6 +132,40 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: a }, []); + // ── Admin: load profiles ── + const loadAdminProfiles = useCallback(async () => { + setAdminLoading(true); + try { + const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' }); + if (resp.ok) { + const d = await resp.json(); + setAdminProfiles(d.profiles || []); + } + } catch { /* silent */ } + finally { setAdminLoading(false); } + }, []); + + // ── Admin: open panel ── + const openAdmin = useCallback(() => { + setShowAdmin(true); + if (isAdmin) loadAdminProfiles(); + }, [isAdmin, loadAdminProfiles]); + + // ── Admin: delete profile ── + const adminDeleteProfile = useCallback(async (profileId: string, displayName: string) => { + if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return; + try { + const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, { + method: 'DELETE', + credentials: 'include', + }); + if (resp.ok) { + loadAdminProfiles(); + fetchProfiles(); + } + } catch { /* silent */ } + }, [loadAdminProfiles, fetchProfiles]); + // ── Steam login ── const connectSteam = useCallback(() => { const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600'); @@ -478,6 +514,11 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: a )}
+ {isAdmin && ( + + )}
{/* ── Profile Chips ── */} @@ -904,6 +945,54 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: a ); })()} + {/* ── Admin Panel ── */} + {showAdmin && ( +
setShowAdmin(false)}> +
e.stopPropagation()}> +
+

⚙️ Game Library Admin

+ +
+ +
+
+ ✅ Eingeloggt als Admin + +
+ + {adminLoading ? ( +
Lade Profile...
+ ) : adminProfiles.length === 0 ? ( +

Keine Profile vorhanden.

+ ) : ( +
+ {adminProfiles.map((p: any) => ( +
+ {p.displayName} +
+ {p.displayName} + + {p.steamName && Steam: {p.steamGames}} + {p.gogName && GOG: {p.gogGames}} + {p.totalGames} Spiele + +
+ +
+ ))} +
+ )} +
+
+
+ )} + {/* ── GOG Code Dialog (browser fallback only) ── */} {gogDialogOpen && (
setGogDialogOpen(false)}> diff --git a/web/src/plugins/game-library/game-library.css b/web/src/plugins/game-library/game-library.css index d4a9c7f..1b791da 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -472,24 +472,29 @@ /* ── 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; + 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 ── */ @@ -772,7 +777,6 @@ align-items: center; justify-content: center; z-index: 1000; - backdrop-filter: blur(4px); } .gl-dialog { @@ -802,7 +806,7 @@ padding: 10px 12px; background: #1a1810; border: 1px solid #444; - border-radius: 8px; + border-radius: 6px; color: #fff; font-size: 0.9rem; outline: none; @@ -844,7 +848,7 @@ background: #322d26; color: #ccc; border: none; - border-radius: 8px; + border-radius: 6px; cursor: pointer; font-size: 0.9rem; } @@ -858,7 +862,7 @@ background: #a855f7; color: #fff; border: none; - border-radius: 8px; + border-radius: 6px; cursor: pointer; font-size: 0.9rem; font-weight: 600; @@ -956,7 +960,7 @@ color: #fff; border: none; padding: 10px 20px; - border-radius: 8px; + border-radius: 6px; cursor: pointer; font-weight: 600; white-space: nowrap; diff --git a/web/src/plugins/lolstats/lolstats.css b/web/src/plugins/lolstats/lolstats.css index 2bda0a2..efa1a28 100644 --- a/web/src/plugins/lolstats/lolstats.css +++ b/web/src/plugins/lolstats/lolstats.css @@ -20,7 +20,7 @@ min-width: 0; padding: 10px 14px; border: 1px solid var(--bg-tertiary); - border-radius: 8px; + border-radius: 4px; background: var(--bg-secondary); color: var(--text-normal); font-size: 15px; @@ -33,7 +33,7 @@ .lol-search-region { padding: 10px 12px; border: 1px solid var(--bg-tertiary); - border-radius: 8px; + border-radius: 4px; background: var(--bg-secondary); color: var(--text-normal); font-size: 14px; @@ -44,7 +44,7 @@ .lol-search-btn { padding: 10px 20px; border: none; - border-radius: 8px; + border-radius: 4px; background: var(--accent); color: #fff; font-weight: 600; @@ -139,7 +139,7 @@ gap: 6px; padding: 8px 16px; border: 1px solid var(--bg-tertiary); - border-radius: 8px; + border-radius: 4px; background: var(--bg-primary); color: var(--text-muted); font-size: 13px; @@ -232,7 +232,7 @@ align-items: center; gap: 8px; padding: 8px 12px; - border-radius: 8px; + border-radius: 4px; background: var(--bg-secondary); min-width: 180px; flex-shrink: 0; @@ -268,7 +268,7 @@ align-items: center; gap: 10px; padding: 10px 12px; - border-radius: 8px; + border-radius: 4px; background: var(--bg-secondary); border-left: 4px solid var(--bg-tertiary); cursor: pointer; @@ -374,7 +374,7 @@ /* ── Match Detail (expanded) ── */ .lol-match-detail { background: var(--bg-primary); - border-radius: 8px; + border-radius: 4px; padding: 8px; margin-top: 4px; margin-bottom: 4px; @@ -447,7 +447,7 @@ .lol-error { padding: 16px; - border-radius: 8px; + border-radius: 4px; background: rgba(231,76,60,0.1); color: #e74c3c; font-size: 13px; @@ -456,22 +456,25 @@ } .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; + 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: var(--text-normal); + letter-spacing: -0.5px; margin: 0; } .lol-empty p { - margin: 0; - font-size: 13px; + font-size: 15px; color: var(--text-muted); + text-align: center; max-width: 360px; line-height: 1.5; margin: 0; } /* ── Load more ── */ @@ -481,7 +484,7 @@ padding: 10px; margin-top: 8px; border: 1px solid var(--bg-tertiary); - border-radius: 8px; + border-radius: 4px; background: transparent; color: var(--text-muted); font-size: 13px; @@ -536,7 +539,7 @@ .lol-tier-filter { padding: 6px 12px; border: 1px solid var(--bg-tertiary); - border-radius: 8px; + border-radius: 4px; background: var(--bg-secondary); color: var(--text-normal); font-size: 12px; diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index a58c165..434f0f2 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -186,6 +186,7 @@ async function apiGetVolume(guildId: string): Promise { return typeof data?.volume === 'number' ? data.volume : 1; } + async function apiAdminDelete(paths: string[]): Promise { const res = await fetch(`${API_BASE}/admin/sounds/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -266,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', @@ -312,7 +305,7 @@ interface SoundboardTabProps { COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function SoundboardTab({ data, isAdmin: isAdminProp }: SoundboardTabProps) { +export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: SoundboardTabProps) { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); @@ -360,7 +353,14 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard const volDebounceRef = useRef>(undefined); /* ── Admin ── */ - const isAdmin = isAdminProp ?? false; + const isAdmin = isAdminProp; + const [showAdmin, setShowAdmin] = useState(false); + const [adminSounds, setAdminSounds] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); + const [adminQuery, setAdminQuery] = useState(''); + const [adminSelection, setAdminSelection] = useState>({}); + const [renameTarget, setRenameTarget] = useState(''); + const [renameValue, setRenameValue] = useState(''); /* ── Drag & Drop Upload ── */ const [isDragging, setIsDragging] = useState(false); @@ -500,7 +500,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard // 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]); @@ -629,6 +629,13 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard return () => document.removeEventListener('click', handler); }, []); + useEffect(() => { + if (showAdmin && isAdmin) { + void loadAdminSounds(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showAdmin, isAdmin]); + /* ── Actions ── */ async function loadAnalytics() { try { @@ -787,6 +794,65 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard 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 ── */ const displaySounds = useMemo(() => { if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); @@ -824,6 +890,26 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard return groups; }, [channels]); + const adminFilteredSounds = useMemo(() => { + const q = adminQuery.trim().toLowerCase(); + if (!q) return adminSounds; + return adminSounds.filter(s => { + const key = soundKey(s).toLowerCase(); + return s.name.toLowerCase().includes(q) + || (s.folder || '').toLowerCase().includes(q) + || key.includes(q); + }); + }, [adminQuery, adminSounds, soundKey]); + + const selectedAdminPaths = useMemo(() => + Object.keys(adminSelection).filter(k => adminSelection[k]), + [adminSelection]); + + const selectedVisibleCount = useMemo(() => + adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, + [adminFilteredSounds, adminSelection, soundKey]); + + const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; const analyticsTop = analytics.mostPlayed.slice(0, 10); const totalSoundsDisplay = analytics.totalSounds || total; @@ -834,122 +920,119 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard RENDER ════════════════════════════════════════════ */ return ( -
+
{chaosMode &&
} - {/* ═══ TOPBAR ═══ */} -
-
-
- music_note -
- Soundboard - - {/* 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 -
- )} -
- )} -
+ {/* ═══ CONTENT HEADER ═══ */} +
+
+ Soundboard + {totalSoundsDisplay}
-
-
{clockMain}{clockSec}
-
- -
- {lastPlayed && ( -
-
-
-
-
- Last Played: {lastPlayed} -
- )} - {selected && ( -
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> - - Verbunden - {voiceStats?.voicePing != null && ( - {voiceStats.voicePing}ms - )} -
- )} -
-
- - {/* ═══ TOOLBAR ═══ */} -
-
- - - -
- -
- search +
+ search setQuery(e.target.value)} /> {query && ( - )}
+
+ {/* Now Playing indicator */} + {lastPlayed && ( +
+
+
+
+
+ Now: {lastPlayed} +
+ )} + + {/* Connection status */} + {selected && ( +
setShowConnModal(true)} + style={{ cursor: 'pointer' }} + title="Verbindungsdetails" + > + + Verbunden + {voiceStats?.voicePing != null && ( + {voiceStats.voicePing}ms + )} +
+ )} + + {/* Admin button */} + {isAdmin && ( + + )} + + {/* Playback controls */} +
+ + + +
+
+
+ + {/* ═══ TOOLBAR ═══ */} +
+ {/* Filter tabs */} + + + + +
+ + {/* URL import */}
{getUrlType(importUrl) === 'youtube' ? 'smart_display' @@ -982,113 +1065,120 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard
-
- -
- { - 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(() => {}); - }, 50); - } - }} - 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 && (
@@ -1111,8 +1201,8 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard
)} - {/* ═══ MAIN ═══ */} -
+ {/* ═══ SOUND GRID ═══ */} +
{displaySounds.length === 0 ? (
{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}
@@ -1129,66 +1219,88 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard : '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 && ( @@ -1215,14 +1327,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard
{ const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; - 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'); - } + await deleteAdminPaths([path]); setCtxMenu(null); }}> delete @@ -1303,6 +1408,142 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard
)} + {/* ═══ ADMIN PANEL ═══ */} + {showAdmin && ( +
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> +
+

+ Admin + +

+
+
+

Eingeloggt als Admin

+
+ +
+
+ +
+ + setAdminQuery(e.target.value)} + placeholder="Nach Name, Ordner oder Pfad filtern..." + /> +
+ +
+ + + +
+ +
+ {adminLoading ? ( +
Lade Sounds...
+ ) : adminFilteredSounds.length === 0 ? ( +
Keine Sounds gefunden.
+ ) : ( +
+ {adminFilteredSounds.map(sound => { + const key = soundKey(sound); + const editing = renameTarget === key; + return ( +
+ + +
+
{sound.name}
+
+ {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} + {' \u00B7 '} + {key} +
+ {editing && ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void submitRename(); + if (e.key === 'Escape') cancelRename(); + }} + placeholder="Neuer Name..." + /> + + +
+ )} +
+ + {!editing && ( +
+ + +
+ )} +
+ ); + })} +
+ )} +
+
+
+
+ )} + {/* ── Drag & Drop Overlay ── */} {isDragging && (
@@ -1426,7 +1667,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp }: Soundboard {dropPhase === 'naming' && (
)} + {isAdmin && ( + + )}
{streams.length === 0 && !isBroadcasting ? ( @@ -801,6 +881,80 @@ export default function StreamingTab({ data, isAdmin: isAdminProp }: { data: any
)} + {/* ── Notification Admin Modal ── */} + {showAdmin && ( +
setShowAdmin(false)}> +
e.stopPropagation()}> +
+

{'\uD83D\uDD14'} Benachrichtigungen

+ +
+ +
+
+ + {notifyStatus.online + ? <>{'\u2705'} Bot online: {notifyStatus.botTag} + : <>{'\u26A0\uFE0F'} Bot offline — DISCORD_TOKEN_NOTIFICATIONS setzen} + +
+ + {configLoading ? ( +
Lade Kan{'\u00E4'}le...
+ ) : availableChannels.length === 0 ? ( +
+ {notifyStatus.online + ? 'Keine Text-Kan\u00E4le gefunden. Bot hat m\u00F6glicherweise keinen Zugriff.' + : 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'} +
+ ) : ( + <> +

+ W{'\u00E4'}hle die Kan{'\u00E4'}le, in die Benachrichtigungen gesendet werden sollen: +

+
+ {availableChannels.map(ch => ( +
+
+ #{ch.channelName} + {ch.guildName} +
+
+ + +
+
+ ))} +
+
+ +
+ + )} +
+
+
+ )}
); } diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css index abdf17a..bb90670 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -373,23 +373,25 @@ /* ── 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; + 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 32684b4..95111c5 100644 --- a/web/src/plugins/watch-together/watch-together.css +++ b/web/src/plugins/watch-together/watch-together.css @@ -161,23 +161,25 @@ /* ── 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; + 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 ── */ @@ -556,7 +558,7 @@ .wt-quality-select { background: var(--bg-secondary, #2a2620); color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #322d26); + border: 1px solid var(--border-color, #3a352d); border-radius: 6px; padding: 2px 6px; font-size: 12px; diff --git a/web/src/styles.css b/web/src/styles.css index 4d5c046..6319c92 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,38 +1,169 @@ -/* ── Google Fonts ── */ +/* ============================================================ + GAMING HUB -- Global Styles + Design System v3.0 -- CI Redesign (warm brown, DM Sans) + ============================================================ */ + +/* -- Google Fonts ------------------------------------------- */ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=DM+Mono:wght@400;500&display=swap'); -/* ── CSS Variables ── */ +/* ============================================================ + A) CSS CUSTOM PROPERTIES + ============================================================ */ :root { - --bg-deep: #1a1810; + /* -- Surface Palette (warm brown) ------------------------- */ + --bg-deepest: #141209; + --bg-deep: #1a1810; --bg-primary: #211e17; --bg-secondary: #2a2620; --bg-tertiary: #322d26; - --bg-card: #2a2620; - --bg-card-hover: #322d26; - --bg-input: #1e1b15; - --bg-header: #1e1b14; - --text-normal: #dbdee1; - --text-muted: #949ba4; - --text-faint: #6d6f78; - --accent: #e67e22; - --accent-rgb: 230, 126, 34; - --accent-hover: #d35400; - --accent-dim: rgba(230, 126, 34, 0.15); - --accent-border: rgba(230, 126, 34, 0.35); + --bg-elevated: #3a352d; + --bg-hover: #3e3830; + --bg-active: #453f36; + --bg-input: #1e1b15; + --bg-header: #1e1b14; + + /* -- Text Colors ------------------------------------------ */ + --text-primary: #dbdee1; + --text-secondary: #949ba4; + --text-tertiary: #6d6f78; + --text-disabled: #4a4940; + + /* -- Semantic Colors -------------------------------------- */ --success: #57d28f; - --danger: #ed4245; --warning: #fee75c; - --border: rgba(255, 255, 255, 0.05); - --border-strong: rgba(255, 255, 255, 0.08); - --radius: 4px; - --radius-lg: 6px; - --transition: 150ms ease; - --font: 'DM Sans', system-ui, -apple-system, sans-serif; - --mono: 'DM Mono', monospace; - --header-height: 44px; + --danger: #ed4245; + --info: #5865f2; + + /* -- Surface Tokens (solid, no glass) --------------------- */ + --surface-glass: rgba(255, 255, 255, .04); + --surface-glass-hover: rgba(255, 255, 255, .07); + --surface-glass-active: rgba(255, 255, 255, .10); + --surface-glass-border: rgba(255, 255, 255, .05); + --surface-glass-border-hover: rgba(255, 255, 255, .10); + + /* -- Border Tokens ---------------------------------------- */ + --border-subtle: rgba(255, 255, 255, .05); + --border-default: rgba(255, 255, 255, .08); + --border-strong: rgba(255, 255, 255, .12); + + /* -- Accent Helpers --------------------------------------- */ + --accent-dim: rgba(230, 126, 34, 0.15); + --accent-border: rgba(230, 126, 34, 0.35); + + /* -- Font Stacks ------------------------------------------ */ + --font-display: 'DM Sans', system-ui, sans-serif; + --font-body: 'DM Sans', system-ui, -apple-system, sans-serif; + --font-mono: 'DM Mono', monospace; + + /* -- Type Scale ------------------------------------------- */ + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-md: 14px; + --text-lg: 16px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 32px; + + /* -- Font Weights ----------------------------------------- */ + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* -- Spacing (4px base grid) ------------------------------ */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-8: 40px; + --space-9: 48px; + --space-10: 64px; + + /* -- Border Radii ----------------------------------------- */ + --radius-xs: 3px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 8px; + --radius-full: 9999px; + + /* -- Shadows / Elevation ---------------------------------- */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, .25); + --shadow-sm: 0 2px 6px rgba(0, 0, 0, .3); + --shadow-md: 0 2px 8px rgba(0, 0, 0, .35); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, .4); + --shadow-xl: 0 8px 24px rgba(0, 0, 0, .45); + + /* -- Motion ----------------------------------------------- */ + --duration-fast: 100ms; + --duration-normal: 150ms; + --duration-slow: 200ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* -- Layout ----------------------------------------------- */ + --sidebar-nav-w: 200px; + --header-h: 44px; } -/* ── Reset & Base ── */ + +/* ============================================================ + B) ACCENT THEME SYSTEM + ============================================================ */ + +/* Default: Ember (orange) */ +:root, +[data-accent="ember"] { + --accent: #e67e22; + --accent-hover: #d35400; + --accent-soft: rgba(230, 126, 34, 0.15); + --accent-text: #f0a050; +} + +[data-accent="amethyst"] { + --accent: #9b59b6; + --accent-hover: #8e44ad; + --accent-soft: rgba(155, 89, 182, 0.15); + --accent-text: #b580d0; +} + +[data-accent="ocean"] { + --accent: #2e86c1; + --accent-hover: #2471a3; + --accent-soft: rgba(46, 134, 193, 0.15); + --accent-text: #5da8d8; +} + +[data-accent="jade"] { + --accent: #27ae60; + --accent-hover: #1e8449; + --accent-soft: rgba(39, 174, 96, 0.15); + --accent-text: #52c47a; +} + +[data-accent="rose"] { + --accent: #e74c8b; + --accent-hover: #c0397a; + --accent-soft: rgba(231, 76, 139, 0.15); + --accent-text: #f07aa8; +} + +[data-accent="crimson"] { + --accent: #d63031; + --accent-hover: #b52728; + --accent-soft: rgba(214, 48, 49, 0.15); + --accent-text: #e25b5c; +} + + +/* ============================================================ + C) GLOBAL RESET & BASE + ============================================================ */ *, *::before, *::after { @@ -41,506 +172,828 @@ box-sizing: border-box; } +html { + scroll-behavior: smooth; +} + html, body { height: 100%; - font-family: var(--font); - font-size: 13px; - color: var(--text-normal); - background: var(--bg-deep); + overflow: hidden; + font-family: var(--font-body); + font-size: var(--text-base); + color: var(--text-primary); + background: var(--bg-primary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - overflow: hidden; } #root { height: 100%; } -/* ── App Shell ── */ -.hub-app { +/* -- Scrollbar ---------------------------------------------- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, .12); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, .2); +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, .12) transparent; +} + +/* -- Selection ---------------------------------------------- */ +::selection { + background: var(--accent-soft); + color: var(--accent-text); +} + +/* -- Focus -------------------------------------------------- */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + + +/* ============================================================ + D) APP LAYOUT (Sidebar) + ============================================================ */ +.app-shell { display: flex; - flex-direction: column; height: 100vh; + width: 100vw; overflow: hidden; } -/* ── Header ── */ -.hub-header { - position: sticky; - top: 0; - z-index: 100; +/* -- Sidebar ------------------------------------------------ */ +.app-sidebar { + width: var(--sidebar-nav-w); + min-width: var(--sidebar-nav-w); + background: var(--bg-deep); display: flex; - align-items: center; - height: var(--header-height); - min-height: var(--header-height); - padding: 0 16px; - background: var(--bg-primary); - border-bottom: 1px solid var(--border); - gap: 16px; + flex-direction: column; + border-right: 1px solid var(--border-subtle); + z-index: 15; } -.hub-header-left { +.sidebar-header { + height: var(--header-h); display: flex; align-items: center; - gap: 10px; + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border-subtle); + gap: var(--space-2); flex-shrink: 0; } -.hub-logo { - font-size: 24px; - line-height: 1; +.sidebar-logo { + width: 32px; + height: 32px; + min-width: 32px; + border-radius: var(--radius-md); + background: var(--accent); + display: grid; + place-items: center; + font-family: var(--font-display); + font-weight: var(--weight-bold); + font-size: var(--text-base); + color: #fff; + /* no glow */ + flex-shrink: 0; } -.hub-title { - font-size: 18px; - font-weight: 700; - color: var(--text-normal); +.sidebar-brand { + font-family: var(--font-display); + font-weight: var(--weight-bold); + font-size: var(--text-md); letter-spacing: -0.02em; + background: var(--accent); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; white-space: nowrap; } -/* ── Connection Status Dot ── */ -.hub-conn-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--danger); - flex-shrink: 0; - transition: background var(--transition); - box-shadow: 0 0 0 2px rgba(237, 66, 69, 0.25); +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: var(--space-2); } -.hub-conn-dot.online { +.sidebar-section-label { + padding: var(--space-5) var(--space-4) var(--space-2); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-tertiary); +} + +/* -- Main --------------------------------------------------- */ +.app-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-primary); + position: relative; +} + +/* -- Content Header ----------------------------------------- */ +.content-header { + height: var(--header-h); + min-height: var(--header-h); + display: flex; + align-items: center; + padding: 0 var(--space-6); + border-bottom: 1px solid var(--border-subtle); + gap: var(--space-4); + position: relative; + z-index: 5; + flex-shrink: 0; +} + +.content-header__title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.content-header__title .sound-count { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--accent); + background: var(--accent-soft); + padding: 2px 8px; + border-radius: var(--radius-full); + font-weight: var(--weight-medium); +} + +.content-header__search { + flex: 1; + max-width: 320px; + height: 34px; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + border-radius: var(--radius-sm); + background: var(--surface-glass); + border: 1px solid var(--surface-glass-border); + color: var(--text-secondary); + font-size: var(--text-sm); + transition: all var(--duration-fast); +} + +.content-header__search:focus-within { + border-color: var(--accent); + background: var(--surface-glass-hover); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.content-header__search input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--text-sm); +} + +.content-header__search input::placeholder { + color: var(--text-tertiary); +} + +.content-header__actions { + display: flex; + align-items: center; + gap: var(--space-2); + margin-left: auto; +} + +/* -- Content Area ------------------------------------------- */ +.content-area { + flex: 1; + overflow-y: auto; + background: var(--bg-primary); + position: relative; +} + + +/* ============================================================ + E) SIDEBAR NAVIGATION ITEMS + ============================================================ */ +.nav-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--duration-fast) ease; + position: relative; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + margin-bottom: 1px; + text-decoration: none; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-item.active { + background: var(--accent-soft); + color: var(--accent-text); +} + +.nav-item.active .nav-icon { + color: var(--accent); +} + +.nav-icon { + width: 20px; + height: 20px; + display: grid; + place-items: center; + font-size: 16px; + flex-shrink: 0; +} + +.nav-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* -- Notification Badge on Nav Items ------------------------ */ +.nav-badge { + margin-left: auto; + background: var(--danger); + color: #fff; + font-size: 10px; + font-weight: var(--weight-bold); + min-width: 18px; + height: 18px; + border-radius: var(--radius-full); + display: grid; + place-items: center; + padding: 0 5px; +} + +/* -- Now-Playing Indicator in Nav --------------------------- */ +.nav-now-playing { + margin-left: auto; + width: 14px; + display: flex; + align-items: flex-end; + gap: 2px; + height: 14px; +} + +.nav-now-playing span { + display: block; + width: 2px; + border-radius: 1px; + background: var(--accent); + animation: eq-bar 1.2s ease-in-out infinite; +} + +.nav-now-playing span:nth-child(1) { height: 40%; animation-delay: 0s; } +.nav-now-playing span:nth-child(2) { height: 70%; animation-delay: 0.2s; } +.nav-now-playing span:nth-child(3) { height: 50%; animation-delay: 0.4s; } + +@keyframes eq-bar { + 0%, 100% { transform: scaleY(0.3); } + 50% { transform: scaleY(1); } +} + + +/* ============================================================ + F) CHANNEL DROPDOWN (in Sidebar) + ============================================================ */ +.channel-dropdown { + position: relative; + margin: 0 var(--space-2); + padding: var(--space-2) 0; +} + +.channel-dropdown__trigger { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--duration-fast); + background: var(--surface-glass); + border: 1px solid var(--surface-glass-border); + width: 100%; + font-family: var(--font-body); + color: var(--text-primary); +} + +.channel-dropdown__trigger:hover { + background: var(--surface-glass-hover); + border-color: var(--surface-glass-border-hover); +} + +.channel-dropdown__trigger .channel-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.channel-dropdown__trigger .channel-name { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-primary); + flex: 1; + text-align: left; +} + +.channel-dropdown__trigger .channel-arrow { + color: var(--text-tertiary); + transition: transform var(--duration-fast); + font-size: var(--text-sm); +} + +.channel-dropdown.open .channel-arrow { + transform: rotate(180deg); +} + +.channel-dropdown__menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: var(--space-1); + z-index: 50; + box-shadow: var(--shadow-lg); + display: none; + animation: dropdown-in var(--duration-normal) var(--ease-out); +} + +.channel-dropdown.open .channel-dropdown__menu { + display: block; +} + +@keyframes dropdown-in { + from { opacity: 0; transform: translateY(-4px); } +} + +.channel-dropdown__item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-xs); + cursor: pointer; + font-size: var(--text-sm); + color: var(--text-secondary); + transition: all var(--duration-fast); + border: none; + background: none; + width: 100%; + font-family: var(--font-body); + text-align: left; +} + +.channel-dropdown__item:hover { + background: var(--surface-glass-hover); + color: var(--text-primary); +} + +.channel-dropdown__item.selected { + background: var(--accent-soft); + color: var(--accent-text); +} + + +/* ============================================================ + G) SIDEBAR FOOTER (User) + ============================================================ */ +.sidebar-footer { + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: var(--space-3); + flex-shrink: 0; +} + +.sidebar-avatar { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--accent); + display: grid; + place-items: center; + font-size: var(--text-base); + font-weight: var(--weight-bold); + color: #fff; + position: relative; + flex-shrink: 0; +} + +.sidebar-avatar .status-dot { + position: absolute; + bottom: -1px; + right: -1px; + width: 12px; + height: 12px; + border-radius: var(--radius-full); + background: var(--success); + border: 2.5px solid var(--bg-deep); +} + +.sidebar-avatar .status-dot.warning { + background: var(--warning); +} + +.sidebar-avatar .status-dot.offline { + background: var(--danger); +} + +@keyframes pulse-status { + 0%, 100% { box-shadow: 0 0 0 0 rgba(87, 210, 143, .4); } + 50% { box-shadow: 0 0 0 5px rgba(87, 210, 143, 0); } +} + +.sidebar-avatar .status-dot.online { + animation: pulse-status 2s ease-in-out infinite; +} + +.sidebar-user-info { + flex: 1; + min-width: 0; +} + +.sidebar-username { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-user-tag { + font-size: var(--text-xs); + color: var(--text-tertiary); +} + +.sidebar-settings { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-tertiary); + transition: all var(--duration-fast); + background: none; + border: none; +} + +.sidebar-settings:hover { + background: var(--surface-glass-hover); + color: var(--text-primary); +} + +.sidebar-settings.admin-active { + color: var(--accent); +} + +/* -- Sidebar Accent Picker ---------------------------------- */ +.sidebar-accent-picker { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-subtle); +} + +.accent-swatch { + width: 18px; + height: 18px; + border-radius: var(--radius-full); + border: 2px solid transparent; + cursor: pointer; + transition: all var(--duration-fast) ease; + flex-shrink: 0; + padding: 0; +} + +.accent-swatch:hover { + transform: scale(1.2); + border-color: rgba(255, 255, 255, .2); +} + +.accent-swatch.active { + border-color: #fff; + transform: scale(1.15); +} + + +/* ============================================================ + H) CONTENT HEADER EXTRAS + ============================================================ */ + +/* -- Playback Controls -------------------------------------- */ +.playback-controls { + display: flex; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-2); + border-left: 1px solid var(--border-subtle); + margin-left: var(--space-2); +} + +.playback-btn { + height: 32px; + padding: 0 var(--space-3); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + border: 1px solid transparent; + transition: all var(--duration-fast); + background: var(--surface-glass); + color: var(--text-secondary); +} + +.playback-btn:hover { + background: var(--surface-glass-hover); + color: var(--text-primary); +} + +.playback-btn--stop:hover { + background: rgba(237, 66, 69, .12); + color: var(--danger); + border-color: rgba(237, 66, 69, .2); +} + +.playback-btn--party { + background: var(--accent); + color: #fff; + font-weight: var(--weight-semibold); +} + +.playback-btn--party:hover { + opacity: 0.85; +} + +/* -- Theme Picker ------------------------------------------- */ +.theme-picker { + display: flex; + gap: var(--space-1); + align-items: center; + padding: var(--space-1); + border-radius: var(--radius-full); + background: var(--surface-glass); + border: 1px solid var(--surface-glass-border); +} + +.theme-swatch { + width: 18px; + height: 18px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: all var(--duration-fast); +} + +.theme-swatch:hover { + transform: scale(1.2); +} + +.theme-swatch.active { + border-color: #fff; + /* no glow */ +} + +.theme-swatch[data-t="ember"] { background: #e67e22; } +.theme-swatch[data-t="amethyst"] { background: #9b59b6; } +.theme-swatch[data-t="ocean"] { background: #2e86c1; } +.theme-swatch[data-t="jade"] { background: #27ae60; } +.theme-swatch[data-t="rose"] { background: #e74c8b; } +.theme-swatch[data-t="crimson"] { background: #d63031; } + +/* -- Connection Badge --------------------------------------- */ +.connection-badge { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-medium); +} + +.connection-badge.connected { + color: var(--success); + background: rgba(87, 210, 143, .1); +} + +.connection-badge .dot { + width: 8px; + height: 8px; + border-radius: 50%; background: var(--success); - box-shadow: 0 0 0 2px rgba(87, 210, 143, 0.25); animation: pulse-dot 2s ease-in-out infinite; } @keyframes pulse-dot { - 0%, 100% { - box-shadow: 0 0 0 2px rgba(87, 210, 143, 0.25); - } - 50% { - box-shadow: 0 0 0 6px rgba(87, 210, 143, 0.1); - } + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(87, 210, 143, .4); } + 50% { opacity: .8; box-shadow: 0 0 0 6px rgba(87, 210, 143, 0); } } -/* ── Tab Navigation ── */ -.hub-tabs { +/* -- Volume Control ----------------------------------------- */ +.volume-control { display: flex; align-items: center; - gap: 4px; - flex: 1; - overflow-x: auto; - scrollbar-width: none; - -ms-overflow-style: none; - padding: 0 4px; -} - -.hub-tabs::-webkit-scrollbar { - display: none; -} - -.hub-tab { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - border: none; - background: transparent; - color: var(--text-muted); - font-family: var(--font); - font-size: 14px; - font-weight: 500; - cursor: pointer; - border-radius: var(--radius); - white-space: nowrap; - transition: all var(--transition); - position: relative; - user-select: none; -} - -.hub-tab:hover { - color: var(--text-normal); - background: var(--bg-secondary); -} - -.hub-tab.active { - color: var(--accent); - background: rgba(var(--accent-rgb), 0.1); -} - -.hub-tab.active::after { - content: ''; - position: absolute; - bottom: -1px; - left: 50%; - transform: translateX(-50%); - width: calc(100% - 16px); - height: 2px; - background: var(--accent); - border-radius: 1px; -} - -.hub-tab-icon { + gap: var(--space-2); + color: var(--text-tertiary); font-size: 16px; - line-height: 1; } -.hub-tab-label { - text-transform: capitalize; -} - -/* ── Header Right ── */ -.hub-header-right { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; -} - -.hub-download-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - font-size: 12px; - font-weight: 500; - font-family: var(--font); - text-decoration: none; - color: var(--text-muted); - background: var(--bg-secondary); - border-radius: var(--radius); +.volume-slider { + -webkit-appearance: none; + appearance: none; + width: 80px; + height: 4px; + border-radius: 2px; + background: var(--bg-elevated); + outline: none; cursor: pointer; - transition: all var(--transition); - white-space: nowrap; -} -.hub-download-btn:hover { - color: var(--accent); - background: rgba(var(--accent-rgb), 0.1); -} -.hub-download-icon { - font-size: 14px; - line-height: 1; -} -.hub-download-label { - line-height: 1; } -.hub-version { - font-size: 12px; - color: var(--text-faint); - font-weight: 500; - font-variant-numeric: tabular-nums; - background: var(--bg-secondary); - padding: 4px 8px; - border-radius: 4px; -} - -/* ── Check for Updates Button ── */ -.hub-check-update-btn { - background: none; - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-secondary); - font-size: 14px; - padding: 2px 8px; +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); cursor: pointer; - transition: all var(--transition); - line-height: 1; -} -.hub-check-update-btn:hover:not(:disabled) { - color: var(--accent); - border-color: var(--accent); -} -.hub-check-update-btn:disabled { - opacity: 0.4; - cursor: default; + border: none; + /* no glow */ } -/* ── Update Modal ── */ +.volume-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: none; + /* no glow */ +} + +.volume-label { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); + min-width: 28px; +} + + +/* ============================================================ + I) MODAL STYLES + ============================================================ */ + +/* -- Modal Overlay (shared) --------------------------------- */ +.hub-admin-overlay, +.hub-version-overlay, .hub-update-overlay { position: fixed; inset: 0; - z-index: 9999; + background: rgba(0, 0, 0, .7); + /* no blur */ + z-index: 1000; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -} -.hub-update-modal { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 6px; - padding: 32px 40px; - text-align: center; - min-width: 320px; - max-width: 400px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} -.hub-update-icon { - font-size: 40px; - margin-bottom: 12px; -} -.hub-update-modal h2 { - margin: 0 0 8px; - font-size: 18px; - color: var(--text-primary); -} -.hub-update-modal p { - margin: 0 0 20px; - font-size: 14px; - color: var(--text-secondary); -} -.hub-update-progress { - height: 4px; - border-radius: 2px; - background: var(--bg-deep); - overflow: hidden; -} -.hub-update-progress-bar { - height: 100%; - width: 40%; - border-radius: 2px; - background: var(--accent); - animation: hub-update-slide 1.5s ease-in-out infinite; -} -@keyframes hub-update-slide { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(350%); } -} -.hub-update-btn { - padding: 8px 32px; - font-size: 14px; - font-weight: 600; - border: none; - border-radius: var(--radius); - background: var(--accent); - color: #fff; - cursor: pointer; - transition: opacity var(--transition); -} -.hub-update-btn:hover { - opacity: 0.85; -} -.hub-update-btn-secondary { - background: var(--bg-tertiary); - color: var(--text-secondary); - margin-top: 4px; -} -.hub-update-versions { - display: flex; - flex-direction: column; - gap: 2px; - margin: 8px 0; - font-size: 12px; - color: var(--text-muted); -} -.hub-update-error-detail { - font-size: 11px; - color: #ef4444; - background: rgba(239, 68, 68, 0.1); - border-radius: var(--radius); - padding: 6px 10px; - word-break: break-word; - max-width: 300px; + animation: modal-fade-in var(--duration-normal) ease; } -/* ── Refresh Button ── */ -.hub-refresh-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 1rem; - cursor: pointer; - padding: 4px 6px; - border-radius: var(--radius); - transition: all var(--transition); - line-height: 1; -} -.hub-refresh-btn:hover { - color: var(--accent); - background: rgba(230, 126, 34, 0.1); +@keyframes modal-fade-in { + from { opacity: 0; } + to { opacity: 1; } } -/* ── Admin Button (header) ── */ -.hub-admin-btn { - background: none; - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-muted); - font-size: 16px; - padding: 4px 8px; - cursor: pointer; - transition: all var(--transition); - line-height: 1; -} -.hub-admin-btn:hover { - color: var(--accent); - border-color: var(--accent); -} -.hub-admin-btn.active { - color: #4ade80; - border-color: #4ade80; +@keyframes modal-slide-in { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } } -/* ── Admin Login Modal ── */ -.hub-admin-overlay { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -} -.hub-admin-modal { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 6px; - width: 340px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} -.hub-admin-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - font-weight: 600; -} -.hub-admin-modal-close { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - font-size: 16px; -} -.hub-admin-modal-close:hover { - color: var(--text); -} -.hub-admin-modal-body { - padding: 20px; - display: flex; - flex-direction: column; - gap: 12px; -} -.hub-admin-input { - width: 100%; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-secondary); - color: var(--text); - font-size: 14px; - font-family: var(--font); - box-sizing: border-box; -} -.hub-admin-input:focus { - outline: none; - border-color: var(--accent); -} -.hub-admin-error { - color: #ef4444; - font-size: 13px; - margin: 0; -} -.hub-admin-submit { - padding: 8px 16px; - background: var(--accent); - color: #fff; - border: none; - border-radius: var(--radius); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: opacity var(--transition); -} -.hub-admin-submit:hover { - opacity: 0.9; -} - -/* ── Version Info Modal ── */ +/* -- Version Info Modal ------------------------------------- */ .hub-version-clickable { cursor: pointer; - transition: all var(--transition); + transition: all var(--duration-fast); padding: 2px 8px; - border-radius: var(--radius); + border-radius: var(--radius-sm); } + .hub-version-clickable:hover { color: var(--accent); - background: rgba(230, 126, 34, 0.1); -} -.hub-version-overlay { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); + background: var(--accent-soft); } + .hub-version-modal { - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); width: 340px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-xl); overflow: hidden; - animation: hub-modal-in 200ms ease; -} -@keyframes hub-modal-in { - from { opacity: 0; transform: scale(0.95) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } + animation: modal-slide-in var(--duration-normal) ease; } + .hub-version-modal-header { display: flex; align-items: center; justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - font-weight: 700; - font-size: 14px; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border-subtle); + font-weight: var(--weight-bold); + font-size: var(--text-base); } + .hub-version-modal-close { background: none; border: none; - color: var(--text-muted); + color: var(--text-tertiary); cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - font-size: 14px; - transition: all var(--transition); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-base); + transition: all var(--duration-fast); } + .hub-version-modal-close:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--text-normal); + background: var(--surface-glass-hover); + color: var(--text-primary); } + .hub-version-modal-body { - padding: 16px; + padding: var(--space-4); display: flex; flex-direction: column; - gap: 12px; + gap: var(--space-3); } + .hub-version-modal-row { display: flex; justify-content: space-between; align-items: center; } + .hub-version-modal-label { - color: var(--text-muted); - font-size: 13px; + color: var(--text-secondary); + font-size: var(--text-sm); } + .hub-version-modal-value { - font-weight: 600; - font-size: 13px; + font-weight: var(--weight-semibold); + font-size: var(--text-sm); display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); } + .hub-version-modal-dot { width: 8px; height: 8px; @@ -548,131 +1001,629 @@ html, body { background: var(--danger); flex-shrink: 0; } + .hub-version-modal-dot.online { background: var(--success); } + .hub-version-modal-link { color: var(--accent); text-decoration: none; - font-weight: 500; - font-size: 13px; + font-weight: var(--weight-medium); + font-size: var(--text-sm); } + .hub-version-modal-link:hover { text-decoration: underline; } + .hub-version-modal-hint { - font-size: 11px; + font-size: var(--text-xs); color: var(--accent); - padding: 6px 10px; - background: rgba(230, 126, 34, 0.1); - border-radius: var(--radius); + padding: var(--space-2) var(--space-3); + background: var(--accent-soft); + border-radius: var(--radius-sm); text-align: center; } -/* ── Update Section in Version Modal ── */ +/* -- Update Section in Version Modal ------------------------ */ .hub-version-modal-update { - margin-top: 4px; - padding-top: 12px; - border-top: 1px solid var(--border); + margin-top: var(--space-1); + padding-top: var(--space-3); + border-top: 1px solid var(--border-subtle); } + .hub-version-modal-update-btn { width: 100%; - padding: 10px 16px; + padding: var(--space-3) var(--space-4); border: none; - border-radius: var(--radius); + border-radius: var(--radius-sm); background: var(--bg-tertiary); - color: var(--text-normal); - font-size: 13px; - font-weight: 600; + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); cursor: pointer; - transition: all var(--transition); + transition: all var(--duration-fast); display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); + font-family: var(--font-body); } + .hub-version-modal-update-btn:hover { background: var(--bg-hover); color: var(--accent); } + .hub-version-modal-update-btn.ready { - background: rgba(46, 204, 113, 0.15); - color: #2ecc71; + background: rgba(87, 210, 143, .15); + color: var(--success); } + .hub-version-modal-update-btn.ready:hover { - background: rgba(46, 204, 113, 0.25); + background: rgba(87, 210, 143, .25); } + .hub-version-modal-update-status { display: flex; align-items: center; justify-content: center; - gap: 8px; - font-size: 13px; - color: var(--text-muted); - padding: 8px 0; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--text-secondary); + padding: var(--space-2) 0; flex-wrap: wrap; } + .hub-version-modal-update-status.success { - color: #2ecc71; + color: var(--success); } + .hub-version-modal-update-status.error { - color: #e74c3c; + color: var(--danger); } + .hub-version-modal-update-retry { background: none; border: none; - color: var(--text-muted); - font-size: 11px; + color: var(--text-secondary); + font-size: var(--text-xs); cursor: pointer; text-decoration: underline; padding: 2px 4px; width: 100%; - margin-top: 4px; + margin-top: var(--space-1); + font-family: var(--font-body); } + .hub-version-modal-update-retry:hover { - color: var(--text-normal); + color: var(--text-primary); } -@keyframes hub-spin { + +/* -- Update Modal (standalone) ------------------------------ */ +.hub-update-modal { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-7) var(--space-8); + text-align: center; + min-width: 320px; + max-width: 400px; + box-shadow: var(--shadow-xl); +} + +.hub-update-icon { + font-size: 40px; + margin-bottom: var(--space-3); +} + +.hub-update-modal h2 { + margin: 0 0 var(--space-2); + font-size: var(--text-lg); + color: var(--text-primary); +} + +.hub-update-modal p { + margin: 0 0 var(--space-5); + font-size: var(--text-base); + color: var(--text-secondary); +} + +.hub-update-progress { + height: 4px; + border-radius: 2px; + background: var(--bg-deep); + overflow: hidden; +} + +.hub-update-progress-bar { + height: 100%; + width: 40%; + border-radius: 2px; + background: var(--accent); + animation: update-slide 1.5s ease-in-out infinite; +} + +@keyframes update-slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + +.hub-update-btn { + padding: var(--space-2) var(--space-7); + font-size: var(--text-base); + font-weight: var(--weight-semibold); + border: none; + border-radius: var(--radius-sm); + background: var(--accent); + color: #fff; + cursor: pointer; + transition: opacity var(--duration-fast); + font-family: var(--font-body); +} + +.hub-update-btn:hover { + opacity: 0.85; +} + +.hub-update-btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + margin-top: var(--space-1); +} + +.hub-update-versions { + display: flex; + flex-direction: column; + gap: 2px; + margin: var(--space-2) 0; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.hub-update-error-detail { + font-size: var(--text-xs); + color: var(--danger); + background: rgba(237, 66, 69, .1); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + word-break: break-word; + max-width: 300px; +} + +/* -- Spinner ------------------------------------------------ */ +@keyframes spin { to { transform: rotate(360deg); } } + .hub-update-spinner { width: 14px; height: 14px; - border: 2px solid var(--border); + border: 2px solid var(--border-default); border-top-color: var(--accent); border-radius: 50%; - animation: hub-spin 0.8s linear infinite; + animation: spin 0.8s linear infinite; flex-shrink: 0; } -/* ── Main Content Area ── */ -.hub-content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; +/* -- Admin Modal -------------------------------------------- */ +.hub-admin-modal { + background: var(--bg-secondary); + border: 1px solid var(--surface-glass-border); + border-radius: var(--radius-lg); + padding: var(--space-7); + width: 360px; + max-width: 90vw; + box-shadow: var(--shadow-xl); + animation: modal-slide-in 0.25s var(--ease-spring); +} + +.hub-admin-modal-title { + font-size: var(--text-xl); + font-weight: var(--weight-bold); + margin-bottom: var(--space-2); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.hub-admin-modal-subtitle { + font-size: var(--text-sm); + color: var(--text-tertiary); + margin-bottom: var(--space-6); +} + +.hub-admin-modal-error { + font-size: var(--text-sm); + color: var(--danger); + margin-bottom: var(--space-3); + padding: var(--space-2) var(--space-3); + background: rgba(237, 66, 69, .1); + border-radius: var(--radius-sm); +} + +.hub-admin-modal-input { + width: 100%; background: var(--bg-deep); - scrollbar-width: thin; - scrollbar-color: var(--bg-tertiary) transparent; + border: 1px solid var(--surface-glass-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--text-base); + padding: var(--space-3) var(--space-3); + outline: none; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); + margin-bottom: var(--space-4); } -.hub-content::-webkit-scrollbar { - width: 6px; +.hub-admin-modal-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } -.hub-content::-webkit-scrollbar-track { +.hub-admin-modal-login { + width: 100%; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font-body); + font-size: var(--text-base); + font-weight: var(--weight-semibold); + padding: var(--space-3); + cursor: pointer; + transition: all var(--duration-fast); + box-shadow: 0 2px 6px rgba(0, 0, 0, .3); +} + +.hub-admin-modal-login:hover { + background: var(--accent-hover); + box-shadow: 0 2px 8px rgba(0, 0, 0, .35); +} + +.hub-admin-modal-info { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-5); +} + +.hub-admin-modal-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + font-weight: var(--weight-bold); + color: #fff; + /* no glow */ +} + +.hub-admin-modal-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.hub-admin-modal-name { + font-weight: var(--weight-semibold); + font-size: var(--text-md); +} + +.hub-admin-modal-role { + font-size: var(--text-sm); + color: var(--success); + font-weight: var(--weight-medium); +} + +.hub-admin-modal-logout { + width: 100%; + background: rgba(237, 66, 69, .12); + border: 1px solid rgba(237, 66, 69, .25); + border-radius: var(--radius-sm); + color: var(--danger); + font-family: var(--font-body); + font-size: var(--text-base); + font-weight: var(--weight-medium); + padding: var(--space-3); + cursor: pointer; + transition: all var(--duration-fast); +} + +.hub-admin-modal-logout:hover { + background: rgba(237, 66, 69, .2); +} + +/* -- Admin Button ------------------------------------------- */ +.hub-admin-btn { + background: var(--surface-glass); + border: 1px solid var(--surface-glass-border); + color: var(--text-secondary); + font-size: 16px; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + transition: all var(--duration-fast); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.hub-admin-btn:hover { + background: var(--surface-glass-hover); + border-color: var(--accent); + box-shadow: 0 0 12px var(--accent-soft); +} + +.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-deep); +} + +/* -- Check for Updates Button ------------------------------- */ +.hub-check-update-btn { + background: none; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--text-base); + padding: 2px var(--space-2); + cursor: pointer; + transition: all var(--duration-fast); + line-height: 1; + font-family: var(--font-body); +} + +.hub-check-update-btn:hover:not(:disabled) { + color: var(--accent); + border-color: var(--accent); +} + +.hub-check-update-btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* -- Version Badge ------------------------------------------ */ +.hub-version { + font-size: var(--text-sm); + color: var(--text-tertiary); + font-weight: var(--weight-medium); + font-variant-numeric: tabular-nums; + background: var(--bg-secondary); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-xs); +} + +/* -- Refresh Button ----------------------------------------- */ +.hub-refresh-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1rem; + cursor: pointer; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + transition: all var(--duration-fast); + line-height: 1; +} + +.hub-refresh-btn:hover { + color: var(--accent); + background: var(--accent-soft); +} + + +/* ============================================================ + J) UTILITY CLASSES + ============================================================ */ + +/* -- Badge -------------------------------------------------- */ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + padding: 2px var(--space-2); + border-radius: var(--radius-xs); + line-height: 1.5; + white-space: nowrap; +} + +.badge--accent { + background: var(--accent-soft); + color: var(--accent-text); +} + +.badge--success { + background: rgba(87, 210, 143, .15); + color: var(--success); +} + +.badge--danger { + background: rgba(237, 66, 69, .15); + color: var(--danger); +} + +.badge--warning { + background: rgba(250, 166, 26, .15); + color: var(--warning); +} + +.badge--info { + background: rgba(88, 101, 242, .15); + color: var(--info); +} + +/* -- Button ------------------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-family: var(--font-body); + font-weight: var(--weight-medium); + border: none; + cursor: pointer; + transition: all var(--duration-fast); + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.btn--sm { height: 24px; padding: 0 var(--space-2); font-size: var(--text-xs); } +.btn--md { height: 26px; padding: 0 var(--space-3); font-size: var(--text-sm); } +.btn--lg { height: 32px; padding: 0 var(--space-4); font-size: var(--text-base); } + +.btn--primary { + background: var(--accent); + color: #fff; +} + +.btn--primary:hover { + background: var(--accent-hover); +} + +.btn--secondary { + background: var(--surface-glass); + color: var(--text-primary); + border: 1px solid var(--surface-glass-border); +} + +.btn--secondary:hover { + background: var(--surface-glass-hover); +} + +.btn--ghost { background: transparent; + color: var(--text-secondary); } -.hub-content::-webkit-scrollbar-thumb { - background: var(--bg-tertiary); - border-radius: 3px; +.btn--ghost:hover { + background: var(--surface-glass); + color: var(--text-primary); } -.hub-content::-webkit-scrollbar-thumb:hover { - background: var(--text-faint); +.btn--danger { + background: var(--danger); + color: #fff; } -/* ── Empty State ── */ +.btn--danger:hover { + background: #d63638; +} + +/* Glass classes kept as simple surface */ +.glass--subtle { + background: var(--surface-glass); + border: 1px solid var(--surface-glass-border); +} + +.glass--medium { + background: rgba(255, 255, 255, .06); + border: 1px solid rgba(255, 255, 255, .10); +} + +.glass--strong { + background: rgba(255, 255, 255, .09); + border: 1px solid rgba(255, 255, 255, .14); +} + +/* -- Toast Notifications ------------------------------------ */ +.toast-container { + position: fixed; + bottom: var(--space-4); + right: var(--space-4); + z-index: 2000; + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background: var(--bg-elevated); + border: 1px solid var(--border-default); + box-shadow: var(--shadow-lg); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + pointer-events: auto; + animation: toast-in var(--duration-normal) var(--ease-out); + max-width: 380px; +} + +.toast.toast-exit { + animation: toast-out var(--duration-fast) ease forwards; +} + +.toast--success { border-left: 3px solid var(--success); } +.toast--danger { border-left: 3px solid var(--danger); } +.toast--warning { border-left: 3px solid var(--warning); } +.toast--info { border-left: 3px solid var(--info); } + +.toast__icon { + font-size: var(--text-lg); + flex-shrink: 0; +} + +.toast__message { + flex: 1; +} + +.toast__close { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: var(--space-1); + border-radius: var(--radius-xs); + font-size: var(--text-sm); + transition: color var(--duration-fast); +} + +.toast__close:hover { + color: var(--text-primary); +} + +@keyframes toast-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes toast-out { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(20px); } +} + + +/* Noise texture removed per CI v2 */ + + +/* ============================================================ + PRESERVED: Empty State + ============================================================ */ .hub-empty { display: flex; flex-direction: column; @@ -681,124 +1632,84 @@ html, body { height: 100%; min-height: 300px; text-align: center; - padding: 32px; + padding: var(--space-7); animation: fade-in 300ms ease; } .hub-empty-icon { font-size: 64px; line-height: 1; - margin-bottom: 20px; + margin-bottom: var(--space-5); opacity: 0.6; filter: grayscale(30%); } .hub-empty h2 { - font-size: 22px; - font-weight: 700; - color: var(--text-normal); - margin-bottom: 8px; + font-size: var(--text-xl); + font-weight: var(--weight-bold); + color: var(--text-primary); + margin-bottom: var(--space-2); } .hub-empty p { - font-size: 15px; - color: var(--text-muted); + font-size: var(--text-md); + color: var(--text-secondary); max-width: 360px; line-height: 1.5; } -/* ── Animations ── */ -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } +/* -- Avatar (general) --------------------------------------- */ +.hub-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-base); + font-weight: var(--weight-bold); + color: #fff; + box-shadow: 0 0 0 2px var(--bg-deep); } -/* ── Selection ── */ -::selection { - background: rgba(var(--accent-rgb), 0.3); - color: var(--text-normal); +/* -- Download Button ---------------------------------------- */ +.hub-download-btn { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + font-family: var(--font-body); + text-decoration: none; + color: var(--text-secondary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--duration-fast); + white-space: nowrap; + border: none; } -/* ── Focus Styles ── */ -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; +.hub-download-btn:hover { + color: var(--accent); + background: var(--accent-soft); } -/* ── Responsive ── */ -@media (max-width: 768px) { - :root { - --header-height: 48px; - } - - .hub-header { - padding: 0 10px; - gap: 8px; - } - - .hub-title { - font-size: 15px; - } - - .hub-logo { - font-size: 20px; - } - - .hub-tab { - padding: 6px 10px; - font-size: 13px; - gap: 4px; - } - - .hub-tab-label { - display: none; - } - - .hub-tab-icon { - font-size: 18px; - } - - .hub-version { - font-size: 11px; - } - - .hub-empty-icon { - font-size: 48px; - } - - .hub-empty h2 { - font-size: 18px; - } - - .hub-empty p { - font-size: 14px; - } +.hub-download-icon { + font-size: var(--text-base); + line-height: 1; } -@media (max-width: 480px) { - .hub-header-right { - display: none; - } - - .hub-header { - padding: 0 8px; - gap: 6px; - } - - .hub-title { - font-size: 14px; - } +.hub-download-label { + line-height: 1; } -/* ══════════════════════════════════════════════ - RADIO PLUGIN – World Radio Globe - ══════════════════════════════════════════════ */ + +/* ============================================================ + PRESERVED: Radio Plugin Styles + ============================================================ */ .radio-container { display: flex; @@ -807,80 +1718,62 @@ html, body { height: 100%; overflow: hidden; background: var(--bg-deep); - - /* Default-Theme Vars (scoped, damit data-theme sie überschreiben kann) */ - --bg-deep: #1a1810; - --bg-primary: #211e17; - --bg-secondary: #2a2620; - --bg-tertiary: #322d26; - --text-normal: #dbdee1; - --text-muted: #949ba4; - --text-faint: #6d6f78; - --accent: #e67e22; - --accent-rgb: 230, 126, 34; - --accent-hover: #d35400; - --border: rgba(255, 255, 255, 0.06); } -/* ── Radio Themes ── */ +/* -- Radio Themes ------------------------------------------- */ .radio-container[data-theme="purple"] { - --bg-deep: #13111c; - --bg-primary: #1a1726; - --bg-secondary: #241f35; - --bg-tertiary: #2e2845; - --accent: #9b59b6; - --accent-rgb: 155, 89, 182; + --bg-deep: #16131c; + --bg-primary: #1d1926; + --bg-secondary: #272235; + --bg-tertiary: #312b42; + --accent: #9b59b6; --accent-hover: #8e44ad; } .radio-container[data-theme="forest"] { - --bg-deep: #0f1a14; - --bg-primary: #142119; - --bg-secondary: #1c2e22; - --bg-tertiary: #253a2c; - --accent: #2ecc71; - --accent-rgb: 46, 204, 113; + --bg-deep: #121a14; + --bg-primary: #172119; + --bg-secondary: #1f2e22; + --bg-tertiary: #283a2c; + --accent: #2ecc71; --accent-hover: #27ae60; } .radio-container[data-theme="ocean"] { - --bg-deep: #0a1628; - --bg-primary: #0f1e33; - --bg-secondary: #162a42; - --bg-tertiary: #1e3652; - --accent: #3498db; - --accent-rgb: 52, 152, 219; + --bg-deep: #101620; + --bg-primary: #151e2c; + --bg-secondary: #1c2a38; + --bg-tertiary: #243646; + --accent: #3498db; --accent-hover: #2980b9; } .radio-container[data-theme="cherry"] { - --bg-deep: #1a0f14; - --bg-primary: #22141a; - --bg-secondary: #301c25; - --bg-tertiary: #3e2530; - --accent: #e74c6f; - --accent-rgb: 231, 76, 111; + --bg-deep: #1a1014; + --bg-primary: #22151a; + --bg-secondary: #301e25; + --bg-tertiary: #3e2830; + --accent: #e74c6f; --accent-hover: #c0392b; } -/* ── Globe ── */ -/* ── Radio Topbar ── */ +/* -- Radio Topbar ------------------------------------------- */ .radio-topbar { display: flex; align-items: center; - padding: 0 16px; - height: 52px; - background: var(--bg-secondary, #2a2620); + padding: 0 var(--space-4); + height: var(--header-h); + background: var(--bg-secondary); border-bottom: 1px solid rgba(0, 0, 0, .24); z-index: 10; flex-shrink: 0; - gap: 16px; + gap: var(--space-4); } .radio-topbar-left { display: flex; align-items: center; - gap: 10px; + gap: var(--space-3); flex-shrink: 0; } @@ -889,17 +1782,17 @@ html, body { } .radio-topbar-title { - font-size: 16px; - font-weight: 700; - color: var(--text-normal); - letter-spacing: -.02em; + font-size: var(--text-lg); + font-weight: var(--weight-bold); + color: var(--text-primary); + letter-spacing: -0.02em; } .radio-topbar-np { flex: 1; display: flex; align-items: center; - gap: 10px; + gap: var(--space-3); min-width: 0; justify-content: center; } @@ -907,7 +1800,7 @@ html, body { .radio-topbar-right { display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); flex-shrink: 0; margin-left: auto; } @@ -915,17 +1808,17 @@ html, body { .radio-topbar-stop { display: flex; align-items: center; - gap: 4px; + gap: var(--space-1); background: var(--danger); color: #fff; border: none; - border-radius: var(--radius); - padding: 6px 14px; - font-size: 13px; - font-family: var(--font); - font-weight: 600; + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + font-family: var(--font-body); + font-weight: var(--weight-semibold); cursor: pointer; - transition: all var(--transition); + transition: all var(--duration-fast); flex-shrink: 0; } @@ -936,11 +1829,11 @@ html, body { .radio-theme-inline { display: flex; align-items: center; - gap: 4px; - margin-left: 4px; + gap: var(--space-1); + margin-left: var(--space-1); } -/* ── Globe Wrapper ── */ +/* -- Globe -------------------------------------------------- */ .radio-globe-wrap { position: relative; flex: 1; @@ -956,10 +1849,10 @@ html, body { outline: none !important; } -/* ── Search Overlay ── */ +/* -- Radio Search ------------------------------------------- */ .radio-search { position: absolute; - top: 16px; + top: var(--space-4); left: 50%; transform: translateX(-50%); z-index: 20; @@ -970,11 +1863,12 @@ html, body { display: flex; align-items: center; background: rgba(33, 30, 23, 0.92); - border: 1px solid var(--border); + /* no blur */ + border: 1px solid var(--border-default); border-radius: var(--radius-lg); - padding: 0 14px; - gap: 8px; - box-shadow: 0 8px 32px rgba(0,0,0,0.4); + padding: 0 var(--space-3); + gap: var(--space-2); + box-shadow: var(--shadow-lg); } .radio-search-icon { @@ -987,60 +1881,59 @@ html, body { flex: 1; background: transparent; border: none; - color: var(--text-normal); - font-family: var(--font); - font-size: 14px; - padding: 12px 0; + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--text-base); + padding: var(--space-3) 0; outline: none; } .radio-search-input::placeholder { - color: var(--text-faint); + color: var(--text-tertiary); } .radio-search-clear { background: none; border: none; - color: var(--text-muted); - font-size: 14px; + color: var(--text-secondary); + font-size: var(--text-base); cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: color var(--transition); + padding: var(--space-1); + border-radius: var(--radius-xs); + transition: color var(--duration-fast); } .radio-search-clear:hover { - color: var(--text-normal); + color: var(--text-primary); } -/* ── Search Results ── */ +/* -- Radio Search Results ----------------------------------- */ .radio-search-results { - margin-top: 6px; + margin-top: var(--space-2); background: rgba(33, 30, 23, 0.95); - border: 1px solid var(--border); + /* no blur */ + border: 1px solid var(--border-default); border-radius: var(--radius-lg); max-height: 360px; overflow-y: auto; - box-shadow: 0 12px 40px rgba(0,0,0,0.5); - scrollbar-width: thin; - scrollbar-color: var(--bg-tertiary) transparent; + box-shadow: var(--shadow-xl); } .radio-search-result { display: flex; align-items: center; - gap: 10px; + gap: var(--space-3); width: 100%; - padding: 10px 14px; + padding: var(--space-3) var(--space-3); background: none; border: none; - border-bottom: 1px solid var(--border); - color: var(--text-normal); - font-family: var(--font); - font-size: 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--text-base); cursor: pointer; text-align: left; - transition: background var(--transition); + transition: background var(--duration-fast); } .radio-search-result:last-child { @@ -1048,11 +1941,11 @@ html, body { } .radio-search-result:hover { - background: rgba(var(--accent-rgb), 0.08); + background: var(--accent-soft); } .radio-search-result-icon { - font-size: 18px; + font-size: var(--text-lg); flex-shrink: 0; } @@ -1064,58 +1957,59 @@ html, body { } .radio-search-result-title { - font-weight: 600; + font-weight: var(--weight-semibold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .radio-search-result-sub { - font-size: 12px; - color: var(--text-muted); + font-size: var(--text-sm); + color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* ── Favorites FAB ── */ +/* -- Favorites FAB ------------------------------------------ */ .radio-fab { position: absolute; - top: 16px; - right: 16px; + top: var(--space-4); + right: var(--space-4); z-index: 20; display: flex; align-items: center; - gap: 4px; - padding: 10px 14px; + gap: var(--space-1); + padding: var(--space-3) var(--space-3); background: rgba(33, 30, 23, 0.92); - border: 1px solid var(--border); + /* no blur */ + border: 1px solid var(--border-default); border-radius: var(--radius-lg); - color: var(--text-normal); + color: var(--text-primary); font-size: 16px; cursor: pointer; - box-shadow: 0 8px 32px rgba(0,0,0,0.4); - transition: all var(--transition); + box-shadow: var(--shadow-lg); + transition: all var(--duration-fast); } .radio-fab:hover, .radio-fab.active { - background: rgba(var(--accent-rgb), 0.15); - border-color: rgba(var(--accent-rgb), 0.3); + background: var(--accent-soft); + border-color: var(--accent); } .radio-fab-badge { - font-size: 11px; - font-weight: 700; + font-size: var(--text-xs); + font-weight: var(--weight-bold); background: var(--accent); color: #fff; padding: 1px 6px; - border-radius: 6px; + border-radius: var(--radius-full); min-width: 18px; text-align: center; } -/* ── Side Panel ── */ +/* -- Side Panel --------------------------------------------- */ .radio-panel { position: absolute; top: 0; @@ -1124,11 +2018,12 @@ html, body { height: 100%; z-index: 15; background: rgba(33, 30, 23, 0.95); - border-left: 1px solid var(--border); + /* no blur */ + border-left: 1px solid var(--border-default); display: flex; flex-direction: column; - animation: slide-in-right 200ms ease; - box-shadow: -8px 0 32px rgba(0,0,0,0.3); + animation: slide-in-right var(--duration-normal) ease; + box-shadow: -8px 0 32px rgba(0, 0, 0, .3); } @keyframes slide-in-right { @@ -1140,20 +2035,20 @@ html, body { display: flex; align-items: center; justify-content: space-between; - padding: 16px; - border-bottom: 1px solid var(--border); + padding: var(--space-4); + border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; } .radio-panel-header h3 { - font-size: 16px; - font-weight: 700; - color: var(--text-normal); + font-size: var(--text-lg); + font-weight: var(--weight-bold); + color: var(--text-primary); } .radio-panel-sub { - font-size: 12px; - color: var(--text-muted); + font-size: var(--text-sm); + color: var(--text-secondary); display: block; margin-top: 2px; } @@ -1161,62 +2056,60 @@ html, body { .radio-panel-close { background: none; border: none; - color: var(--text-muted); - font-size: 18px; + color: var(--text-secondary); + font-size: var(--text-lg); cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - transition: all var(--transition); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-xs); + transition: all var(--duration-fast); } .radio-panel-close:hover { - color: var(--text-normal); - background: var(--bg-secondary); + color: var(--text-primary); + background: var(--surface-glass-hover); } .radio-panel-body { flex: 1; overflow-y: auto; - padding: 8px; - scrollbar-width: thin; - scrollbar-color: var(--bg-tertiary) transparent; + padding: var(--space-2); } .radio-panel-empty { text-align: center; - color: var(--text-muted); - padding: 40px 16px; - font-size: 14px; + color: var(--text-secondary); + padding: var(--space-8) var(--space-4); + font-size: var(--text-base); } .radio-panel-loading { display: flex; flex-direction: column; align-items: center; - gap: 12px; - padding: 40px 16px; - color: var(--text-muted); - font-size: 14px; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + color: var(--text-secondary); + font-size: var(--text-base); } -/* ── Station Card ── */ +/* -- Station Card ------------------------------------------- */ .radio-station { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px; - border-radius: var(--radius); - transition: background var(--transition); - gap: 10px; + padding: var(--space-3) var(--space-3); + border-radius: var(--radius-sm); + transition: background var(--duration-fast); + gap: var(--space-3); } .radio-station:hover { - background: var(--bg-secondary); + background: var(--surface-glass-hover); } .radio-station.playing { - background: rgba(var(--accent-rgb), 0.1); - border: 1px solid rgba(var(--accent-rgb), 0.2); + background: var(--accent-soft); + border: 1px solid var(--accent); } .radio-station-info { @@ -1228,17 +2121,17 @@ html, body { } .radio-station-name { - font-size: 14px; - font-weight: 600; - color: var(--text-normal); + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .radio-station-loc { - font-size: 11px; - color: var(--text-faint); + font-size: var(--text-xs); + color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1247,31 +2140,31 @@ html, body { .radio-station-live { display: flex; align-items: center; - gap: 6px; - font-size: 11px; + gap: var(--space-2); + font-size: var(--text-xs); color: var(--accent); - font-weight: 600; + font-weight: var(--weight-semibold); } .radio-station-btns { display: flex; - gap: 4px; + gap: var(--space-1); flex-shrink: 0; } -/* ── Buttons ── */ +/* -- Radio Buttons ------------------------------------------ */ .radio-btn-play, .radio-btn-stop { width: 34px; height: 34px; border: none; border-radius: 50%; - font-size: 14px; + font-size: var(--text-base); cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all var(--transition); + transition: all var(--duration-fast); } .radio-btn-play { @@ -1306,23 +2199,23 @@ html, body { font-size: 16px; cursor: pointer; background: transparent; - color: var(--text-faint); + color: var(--text-tertiary); display: flex; align-items: center; justify-content: center; - transition: all var(--transition); + transition: all var(--duration-fast); } .radio-btn-fav:hover { color: var(--warning); - background: rgba(254, 231, 92, 0.1); + background: rgba(250, 166, 26, .1); } .radio-btn-fav.active { color: var(--warning); } -/* ── Equalizer Animation ── */ +/* -- Equalizer Animation ------------------------------------ */ .radio-eq { display: flex; align-items: flex-end; @@ -1337,23 +2230,23 @@ html, body { animation: eq-bounce 0.8s ease-in-out infinite; } -.radio-eq span:nth-child(1) { height: 8px; animation-delay: 0s; } +.radio-eq span:nth-child(1) { height: 8px; animation-delay: 0s; } .radio-eq span:nth-child(2) { height: 14px; animation-delay: 0.15s; } .radio-eq span:nth-child(3) { height: 10px; animation-delay: 0.3s; } @keyframes eq-bounce { 0%, 100% { transform: scaleY(0.4); } - 50% { transform: scaleY(1); } + 50% { transform: scaleY(1); } } .radio-sel { background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-normal); - font-family: var(--font); - font-size: 13px; - padding: 6px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--text-sm); + padding: var(--space-2) var(--space-3); cursor: pointer; outline: none; max-width: 180px; @@ -1376,28 +2269,27 @@ html, body { } .radio-np-name { - font-size: 14px; - font-weight: 600; - color: var(--text-normal); + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .radio-np-loc { - font-size: 11px; - color: var(--text-muted); + font-size: var(--text-xs); + color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - -/* ── Volume Slider ── */ +/* -- Radio Volume ------------------------------------------- */ .radio-volume { display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); flex-shrink: 0; } @@ -1414,7 +2306,7 @@ html, body { width: 100px; height: 4px; border-radius: 2px; - background: var(--bg-tertiary, #3a352d); + background: var(--bg-tertiary); outline: none; cursor: pointer; } @@ -1425,25 +2317,25 @@ html, body { width: 14px; height: 14px; border-radius: 50%; - background: var(--accent, #e67e22); + background: var(--accent); cursor: pointer; border: none; - box-shadow: 0 0 4px rgba(0,0,0,0.3); + box-shadow: 0 0 4px rgba(0, 0, 0, .3); } .radio-volume-slider::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; - background: var(--accent, #e67e22); + background: var(--accent); cursor: pointer; border: none; - box-shadow: 0 0 4px rgba(0,0,0,0.3); + box-shadow: 0 0 4px rgba(0, 0, 0, .3); } .radio-volume-val { - font-size: 11px; - color: var(--text-muted); + font-size: var(--text-xs); + color: var(--text-secondary); min-width: 32px; text-align: right; } @@ -1463,42 +2355,43 @@ html, body { .radio-theme-dot.active { border-color: #fff; + box-shadow: 0 0 6px rgba(255, 255, 255, .3); } -/* ── Station count ── */ +/* -- Radio Overlays ----------------------------------------- */ .radio-counter { position: absolute; - bottom: 16px; - left: 16px; + bottom: var(--space-4); + left: var(--space-4); z-index: 10; - font-size: 12px; - color: var(--text-faint); + font-size: var(--text-sm); + color: var(--text-tertiary); background: rgba(33, 30, 23, 0.8); - padding: 4px 10px; - border-radius: 20px; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); pointer-events: none; } .radio-attribution { position: absolute; - right: 16px; - bottom: 16px; + right: var(--space-4); + bottom: var(--space-4); z-index: 10; - font-size: 12px; - color: var(--text-faint); + font-size: var(--text-sm); + color: var(--text-tertiary); background: rgba(33, 30, 23, 0.8); - padding: 4px 10px; - border-radius: 20px; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); text-decoration: none; - transition: color var(--transition), background var(--transition); + transition: color var(--duration-fast), background var(--duration-fast); } .radio-attribution:hover { - color: var(--text-normal); + color: var(--text-primary); background: rgba(33, 30, 23, 0.92); } -/* ── Spinner ── */ +/* -- Radio Spinner ------------------------------------------ */ .radio-spinner { width: 24px; height: 24px; @@ -1508,32 +2401,223 @@ html, body { animation: spin 0.7s linear infinite; } -@keyframes spin { - to { transform: rotate(360deg); } +/* -- Radio Connection --------------------------------------- */ +.radio-conn { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--success); + cursor: pointer; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + background: rgba(87, 210, 143, .08); + transition: all var(--duration-fast); + flex-shrink: 0; + user-select: none; } -/* ── Radio Responsive ── */ +.radio-conn:hover { + background: rgba(87, 210, 143, .15); +} + +.radio-conn-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + animation: pulse-dot 2s ease-in-out infinite; +} + +.radio-conn-ping { + font-size: var(--text-xs); + color: var(--text-secondary); + font-weight: var(--weight-semibold); + font-variant-numeric: tabular-nums; +} + +/* -- Radio Connection Modal --------------------------------- */ +.radio-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .55); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + /* no blur */ + animation: fade-in .15s ease; +} + +.radio-modal { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + width: 340px; + box-shadow: var(--shadow-xl); + overflow: hidden; + animation: radio-modal-in var(--duration-normal) ease; +} + +@keyframes radio-modal-in { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.radio-modal-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border-subtle); + font-weight: var(--weight-bold); + font-size: var(--text-base); +} + +.radio-modal-close { + margin-left: auto; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-base); + transition: all var(--duration-fast); +} + +.radio-modal-close:hover { + background: var(--surface-glass-hover); + color: var(--text-primary); +} + +.radio-modal-body { + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.radio-modal-stat { + display: flex; + justify-content: space-between; + align-items: center; +} + +.radio-modal-label { + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.radio-modal-value { + font-weight: var(--weight-semibold); + font-size: var(--text-sm); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.radio-modal-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + + +/* ============================================================ + ANIMATIONS (Shared) + ============================================================ */ +@keyframes fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + + +/* ============================================================ + L) RESPONSIVE + ============================================================ */ + +/* -- Tablet (< 768px): icon-only sidebar -------------------- */ @media (max-width: 768px) { + :root { + --sidebar-nav-w: 48px; + } + + .sidebar-brand, + .sidebar-section-label, + .nav-label, + .sidebar-user-info, + .channel-dropdown { + display: none; + } + + .sidebar-header { + justify-content: center; + padding: 0; + } + + .sidebar-footer { + justify-content: center; + padding: var(--space-2); + } + + .sidebar-settings { + display: none; + } + + .nav-item { + justify-content: center; + padding: var(--space-2); + } + + .nav-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 14px; + height: 14px; + font-size: 9px; + padding: 0 3px; + } + + .nav-now-playing { + position: absolute; + bottom: 2px; + right: 4px; + margin-left: 0; + } + + .content-header { + padding: 0 var(--space-3); + gap: var(--space-2); + } + + .content-header__search { + max-width: 200px; + } + + /* Radio responsive */ .radio-panel { width: 100%; } .radio-fab { - top: 12px; - right: 12px; - padding: 8px 10px; - font-size: 14px; + top: var(--space-3); + right: var(--space-3); + padding: var(--space-2) var(--space-3); + font-size: var(--text-base); } .radio-search { - top: 12px; + top: var(--space-3); width: calc(100% - 80px); left: calc(50% - 24px); } .radio-topbar { - padding: 0 12px; - gap: 8px; + padding: 0 var(--space-3); + gap: var(--space-2); } .radio-topbar-title { @@ -1542,11 +2626,45 @@ html, body { .radio-sel { max-width: 140px; - font-size: 12px; + font-size: var(--text-sm); + } + + .hub-empty-icon { + font-size: 48px; + } + + .hub-empty h2 { + font-size: var(--text-lg); + } + + .hub-empty p { + font-size: var(--text-base); } } +/* -- Mobile (< 480px): hide less important controls --------- */ @media (max-width: 480px) { + .content-header__actions { + display: none; + } + + .content-header__search { + max-width: none; + flex: 1; + } + + .playback-controls { + display: none; + } + + .theme-picker { + display: none; + } + + .volume-control { + display: none; + } + .radio-topbar-np { display: none; } @@ -1559,1356 +2677,3 @@ html, body { max-width: 120px; } } - -/* ── Radio Connection Indicator ── */ -.radio-conn { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--success); - cursor: pointer; - padding: 4px 10px; - border-radius: 20px; - background: rgba(87, 210, 143, 0.08); - transition: all var(--transition); - flex-shrink: 0; - user-select: none; -} - -.radio-conn:hover { - background: rgba(87, 210, 143, 0.15); -} - -.radio-conn-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--success); - animation: pulse-dot 2s ease-in-out infinite; -} - -.radio-conn-ping { - font-size: 11px; - color: var(--text-muted); - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -/* ── Radio Connection Modal ── */ -.radio-modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, .55); - z-index: 9000; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(4px); - animation: fade-in .15s ease; -} - -.radio-modal { - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 4px; - width: 340px; - box-shadow: 0 20px 60px rgba(0, 0, 0, .4); - overflow: hidden; - animation: radio-modal-in .2s ease; -} - -@keyframes radio-modal-in { - from { transform: translateY(20px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -.radio-modal-header { - display: flex; - align-items: center; - gap: 8px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - font-weight: 700; - font-size: 14px; -} - -.radio-modal-close { - margin-left: auto; - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - font-size: 14px; - transition: all var(--transition); -} - -.radio-modal-close:hover { - background: rgba(255, 255, 255, .08); - color: var(--text-normal); -} - -.radio-modal-body { - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.radio-modal-stat { - display: flex; - justify-content: space-between; - align-items: center; -} - -.radio-modal-label { - color: var(--text-muted); - font-size: 13px; -} - -.radio-modal-value { - font-weight: 600; - font-size: 13px; - display: flex; - align-items: center; - gap: 6px; -} - -.radio-modal-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -/* ══════════════════════════════════════════════ - UNIFIED ADMIN PANEL (ap-*) - ══════════════════════════════════════════════ */ - -.ap-overlay { - position: fixed; - inset: 0; - z-index: 9998; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(6px); - animation: fade-in 150ms ease; -} - -.ap-modal { - display: flex; - width: min(940px, calc(100vw - 40px)); - height: min(620px, calc(100vh - 60px)); - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 4px; - overflow: hidden; - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5); - animation: hub-modal-in 200ms ease; - position: relative; -} - -/* ── Sidebar ── */ -.ap-sidebar { - width: 210px; - min-width: 210px; - background: var(--bg-deep); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - padding: 0; -} - -.ap-sidebar-title { - padding: 18px 20px 14px; - font-size: 15px; - font-weight: 700; - color: var(--text-normal); - letter-spacing: -0.01em; - border-bottom: 1px solid var(--border); -} - -.ap-nav { - display: flex; - flex-direction: column; - padding: 8px; - gap: 2px; -} - -.ap-nav-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border: none; - border-left: 3px solid transparent; - background: transparent; - color: var(--text-muted); - font-family: var(--font); - font-size: 14px; - font-weight: 500; - cursor: pointer; - border-radius: 0 var(--radius) var(--radius) 0; - transition: all var(--transition); - text-align: left; -} - -.ap-nav-item:hover { - color: var(--text-normal); - background: var(--bg-secondary); -} - -.ap-nav-item.active { - color: var(--accent); - background: rgba(var(--accent-rgb), 0.1); - border-left-color: var(--accent); - font-weight: 600; -} - -.ap-nav-icon { - font-size: 16px; - line-height: 1; -} - -.ap-nav-label { - line-height: 1; -} - -.ap-logout-btn { - margin-top: auto; - padding: 10px 16px; - background: transparent; - border: none; - border-top: 1px solid var(--border); - color: #e74c3c; - font-size: 0.85rem; - cursor: pointer; - text-align: left; - transition: background 0.15s; -} -.ap-logout-btn:hover { - background: rgba(231, 76, 60, 0.1); -} - -/* ── Content Area ── */ -.ap-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.ap-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} - -.ap-title { - font-size: 16px; - font-weight: 700; - color: var(--text-normal); - margin: 0; -} - -.ap-close { - background: none; - border: none; - color: var(--text-muted); - font-size: 16px; - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - transition: all var(--transition); -} - -.ap-close:hover { - color: var(--text-normal); - background: rgba(255, 255, 255, 0.08); -} - -.ap-body { - flex: 1; - overflow-y: auto; - padding: 16px 20px; - scrollbar-width: thin; - scrollbar-color: var(--bg-tertiary) transparent; -} - -.ap-body::-webkit-scrollbar { - width: 6px; -} -.ap-body::-webkit-scrollbar-thumb { - background: var(--bg-tertiary); - border-radius: 3px; -} - -.ap-tab-content { - display: flex; - flex-direction: column; - gap: 12px; - animation: fade-in 150ms ease; -} - -/* ── Toolbar ── */ -.ap-toolbar { - display: flex; - align-items: center; - gap: 10px; -} - -.ap-search { - flex: 1; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-secondary); - color: var(--text-normal); - font-size: 13px; - font-family: var(--font); -} - -.ap-search:focus { - outline: none; - border-color: var(--accent); -} - -.ap-search::placeholder { - color: var(--text-faint); -} - -/* ── Buttons ── */ -.ap-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 8px 14px; - border: none; - border-radius: var(--radius); - font-family: var(--font); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition); - white-space: nowrap; -} - -.ap-btn:disabled { - opacity: 0.5; - cursor: default; -} - -.ap-btn-primary { - background: var(--accent); - color: #fff; -} -.ap-btn-primary:hover:not(:disabled) { - background: var(--accent-hover); -} - -.ap-btn-danger { - background: rgba(237, 66, 69, 0.15); - color: var(--danger); - border: 1px solid rgba(237, 66, 69, 0.3); -} -.ap-btn-danger:hover:not(:disabled) { - background: rgba(237, 66, 69, 0.25); -} - -.ap-btn-outline { - background: var(--bg-secondary); - color: var(--text-muted); - border: 1px solid var(--border); -} -.ap-btn-outline:hover:not(:disabled) { - color: var(--text-normal); - border-color: var(--text-faint); -} - -.ap-btn-sm { - padding: 5px 10px; - font-size: 12px; -} - -/* ── Upload Zone ── */ -.ap-upload-zone { - display: flex; - align-items: center; - justify-content: center; - padding: 14px; - border: 2px dashed var(--border); - border-radius: var(--radius); - background: var(--bg-secondary); - cursor: pointer; - transition: all var(--transition); - color: var(--text-muted); - font-size: 13px; -} - -.ap-upload-zone:hover { - border-color: var(--accent); - color: var(--accent); - background: rgba(var(--accent-rgb), 0.05); -} - -.ap-upload-progress { - color: var(--accent); - font-weight: 600; -} - -/* ── Bulk Row ── */ -.ap-bulk-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 0; -} - -.ap-select-all { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--text-muted); - cursor: pointer; -} - -.ap-select-all input[type="checkbox"] { - accent-color: var(--accent); -} - -/* ── Sound List ── */ -.ap-list-wrap { - flex: 1; - min-height: 0; -} - -.ap-list { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ap-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: var(--radius); - background: var(--bg-secondary); - transition: background var(--transition); -} - -.ap-item:hover { - background: var(--bg-tertiary); -} - -.ap-item-check { - flex-shrink: 0; - cursor: pointer; -} - -.ap-item-check input[type="checkbox"] { - accent-color: var(--accent); -} - -.ap-item-main { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.ap-item-name { - font-size: 13px; - font-weight: 600; - color: var(--text-normal); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ap-item-meta { - font-size: 11px; - color: var(--text-faint); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ap-item-actions { - display: flex; - gap: 4px; - flex-shrink: 0; -} - -.ap-rename-row { - display: flex; - align-items: center; - gap: 6px; - margin-top: 4px; -} - -.ap-rename-input { - flex: 1; - padding: 5px 8px; - border: 1px solid var(--accent); - border-radius: var(--radius); - background: var(--bg-deep); - color: var(--text-normal); - font-size: 12px; - font-family: var(--font); -} - -.ap-rename-input:focus { - outline: none; -} - -/* ── Empty ── */ -.ap-empty { - text-align: center; - color: var(--text-muted); - padding: 40px 16px; - font-size: 13px; -} - -/* ── Hint ── */ -.ap-hint { - font-size: 13px; - color: var(--text-muted); - margin: 0; -} - -/* ── Status Badge ── */ -.ap-status-badge { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-muted); -} - -.ap-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--danger); - flex-shrink: 0; -} - -.ap-status-dot.online { - background: var(--success); - animation: pulse-dot 2s ease-in-out infinite; -} - -/* ── Channel List (Streaming) ── */ -.ap-channel-list { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ap-channel-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - border-radius: var(--radius); - background: var(--bg-secondary); - transition: background var(--transition); -} - -.ap-channel-row:hover { - background: var(--bg-tertiary); -} - -.ap-channel-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.ap-channel-name { - font-size: 13px; - font-weight: 600; - color: var(--text-normal); -} - -.ap-channel-guild { - font-size: 11px; - color: var(--text-faint); -} - -.ap-channel-toggles { - display: flex; - gap: 8px; - flex-shrink: 0; -} - -.ap-toggle { - display: flex; - align-items: center; - gap: 4px; - font-size: 12px; - color: var(--text-muted); - cursor: pointer; - padding: 4px 8px; - border-radius: var(--radius); - transition: all var(--transition); -} - -.ap-toggle input[type="checkbox"] { - accent-color: var(--accent); -} - -.ap-toggle.active { - color: var(--accent); - background: rgba(var(--accent-rgb), 0.08); -} - -/* ── Save Row ── */ -.ap-save-row { - display: flex; - justify-content: flex-end; - padding-top: 8px; -} - -/* ── Profile List (Game Library) ── */ -.ap-profile-list { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ap-profile-row { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-radius: var(--radius); - background: var(--bg-secondary); - transition: background var(--transition); -} - -.ap-profile-row:hover { - background: var(--bg-tertiary); -} - -.ap-profile-avatar { - width: 36px; - height: 36px; - border-radius: 50%; - flex-shrink: 0; -} - -.ap-profile-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.ap-profile-name { - font-size: 14px; - font-weight: 600; - color: var(--text-normal); -} - -.ap-profile-details { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; -} - -.ap-platform-badge { - padding: 1px 6px; - border-radius: 3px; - font-weight: 600; - font-size: 10px; -} - -.ap-platform-badge.steam { - background: rgba(66, 133, 244, 0.15); - color: #64b5f6; -} - -.ap-platform-badge.gog { - background: rgba(171, 71, 188, 0.15); - color: #ce93d8; -} - -.ap-profile-total { - color: var(--text-faint); -} - -/* ── Toast ── */ -.ap-toast { - position: absolute; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - padding: 8px 16px; - border-radius: var(--radius); - font-size: 13px; - font-weight: 500; - background: var(--bg-tertiary); - color: var(--text-normal); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - animation: fade-in 150ms ease; - z-index: 10; -} - -.ap-toast.error { - background: rgba(237, 66, 69, 0.2); - color: #f87171; -} - -/* ── Admin Panel Responsive ── */ -@media (max-width: 768px) { - .ap-modal { - flex-direction: column; - width: calc(100vw - 16px); - height: calc(100vh - 32px); - } - - .ap-sidebar { - width: 100%; - min-width: 100%; - flex-direction: row; - border-right: none; - border-bottom: 1px solid var(--border); - overflow-x: auto; - } - - .ap-sidebar-title { - display: none; - } - - .ap-nav { - flex-direction: row; - padding: 4px 8px; - gap: 4px; - } - - .ap-nav-item { - border-left: none; - border-bottom: 3px solid transparent; - border-radius: var(--radius) var(--radius) 0 0; - padding: 8px 12px; - white-space: nowrap; - } - - .ap-nav-item.active { - border-left-color: transparent; - border-bottom-color: var(--accent); - } - - .ap-channel-toggles { - flex-direction: column; - gap: 4px; - } -} - -/* ════════════════════════════════════════════════════════════════════════════ - Unified Login Button (Header) - ════════════════════════════════════════════════════════════════════════════ */ -.hub-user-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: transparent; - color: var(--text-muted); - font-family: var(--font); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition); - line-height: 1; - white-space: nowrap; -} -.hub-user-btn:hover { - color: var(--accent); - border-color: var(--accent); - background: rgba(var(--accent-rgb), 0.06); -} -.hub-user-btn.logged-in { - border-color: rgba(var(--accent-rgb), 0.3); - color: var(--text-normal); -} -.hub-user-btn.admin { - border-color: #4ade80; - color: #4ade80; -} -.hub-user-avatar { - width: 24px; - height: 24px; - border-radius: 50%; - object-fit: cover; -} -.hub-user-icon { - font-size: 16px; - line-height: 1; -} -.hub-user-label { - line-height: 1; -} - -/* ════════════════════════════════════════════════════════════════════════════ - Login Modal - ════════════════════════════════════════════════════════════════════════════ */ -.hub-login-overlay { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -} -.hub-login-modal { - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 4px; - width: 380px; - max-width: 92vw; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); - overflow: hidden; - animation: hub-modal-in 200ms ease; -} -.hub-login-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - font-weight: 700; - font-size: 15px; -} -.hub-login-modal-close { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - font-size: 16px; - padding: 4px; - border-radius: 4px; - transition: all var(--transition); -} -.hub-login-modal-close:hover { - color: var(--text-normal); - background: var(--bg-tertiary); -} -.hub-login-modal-body { - padding: 20px; -} -.hub-login-subtitle { - color: var(--text-muted); - font-size: 13px; - margin: 0 0 16px; - line-height: 1.4; -} - -/* Provider Buttons */ -.hub-login-providers { - display: flex; - flex-direction: column; - gap: 10px; -} -.hub-login-provider-btn { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--bg-secondary); - color: var(--text-normal); - font-family: var(--font); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition); - text-decoration: none; - position: relative; -} -.hub-login-provider-btn:hover:not(:disabled) { - border-color: var(--accent); - background: rgba(var(--accent-rgb), 0.06); - transform: translateY(-1px); -} -.hub-login-provider-btn:active:not(:disabled) { - transform: translateY(0); -} -.hub-login-provider-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} -.hub-login-provider-btn.discord:hover:not(:disabled) { - border-color: #5865F2; - background: rgba(88, 101, 242, 0.08); -} -.hub-login-provider-btn.steam:hover { - border-color: #66c0f4; - background: rgba(102, 192, 244, 0.08); -} -.hub-login-provider-btn.admin:hover { - border-color: var(--accent); -} -.hub-login-provider-icon { - flex-shrink: 0; - width: 22px; - height: 22px; -} -.hub-login-provider-icon-emoji { - font-size: 20px; - line-height: 1; - flex-shrink: 0; -} -.hub-login-soon { - position: absolute; - right: 12px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - color: var(--text-faint); - background: var(--bg-tertiary); - padding: 2px 6px; - border-radius: 4px; - letter-spacing: 0.5px; -} -.hub-login-hint { - margin: 16px 0 0; - font-size: 12px; - color: var(--text-faint); - line-height: 1.4; -} -.hub-login-back { - background: none; - border: none; - color: var(--text-muted); - font-family: var(--font); - font-size: 13px; - cursor: pointer; - padding: 0; - margin-bottom: 16px; - transition: color var(--transition); -} -.hub-login-back:hover { - color: var(--accent); -} - -/* Admin form inside login modal */ -.hub-login-admin-form { - display: flex; - flex-direction: column; - gap: 12px; -} -.hub-login-admin-label { - font-size: 14px; - font-weight: 600; - color: var(--text-normal); -} -.hub-login-admin-input { - width: 100%; - padding: 10px 14px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-secondary); - color: var(--text-normal); - font-size: 14px; - font-family: var(--font); - box-sizing: border-box; - transition: border-color var(--transition); -} -.hub-login-admin-input:focus { - outline: none; - border-color: var(--accent); -} -.hub-login-admin-error { - color: var(--danger); - font-size: 13px; - margin: 0; -} -.hub-login-admin-submit { - padding: 10px 16px; - background: var(--accent); - color: #fff; - border: none; - border-radius: var(--radius); - font-size: 14px; - font-weight: 600; - font-family: var(--font); - cursor: pointer; - transition: opacity var(--transition); -} -.hub-login-admin-submit:hover:not(:disabled) { - opacity: 0.9; -} -.hub-login-admin-submit:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* ════════════════════════════════════════════════════════════════════════════ - User Settings Panel - ════════════════════════════════════════════════════════════════════════════ */ -.hub-usettings-overlay { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -} -.hub-usettings-panel { - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 4px; - width: 520px; - max-width: 95vw; - max-height: 85vh; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); - overflow: hidden; - display: flex; - flex-direction: column; - animation: hub-modal-in 200ms ease; -} - -/* Header */ -.hub-usettings-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} -.hub-usettings-user { - display: flex; - align-items: center; - gap: 12px; -} -.hub-usettings-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - border: 2px solid rgba(var(--accent-rgb), 0.3); -} -.hub-usettings-avatar-placeholder { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--accent); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - font-weight: 700; -} -.hub-usettings-user-info { - display: flex; - flex-direction: column; - gap: 2px; -} -.hub-usettings-username { - font-weight: 600; - font-size: 15px; - color: var(--text-normal); -} -.hub-usettings-discriminator { - font-size: 12px; - color: var(--text-muted); -} -.hub-usettings-header-actions { - display: flex; - align-items: center; - gap: 8px; -} -.hub-usettings-logout { - background: none; - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-muted); - font-size: 16px; - padding: 6px 8px; - cursor: pointer; - transition: all var(--transition); - line-height: 1; -} -.hub-usettings-logout:hover { - color: var(--danger); - border-color: var(--danger); -} -.hub-usettings-close { - background: none; - border: none; - color: var(--text-muted); - font-size: 16px; - cursor: pointer; - padding: 6px; - border-radius: 4px; - transition: all var(--transition); -} -.hub-usettings-close:hover { - color: var(--text-normal); - background: var(--bg-tertiary); -} - -/* Toast */ -.hub-usettings-toast { - padding: 8px 16px; - font-size: 13px; - text-align: center; - animation: hub-toast-in 300ms ease; -} -.hub-usettings-toast.success { - background: rgba(87, 210, 143, 0.1); - color: var(--success); -} -.hub-usettings-toast.error { - background: rgba(237, 66, 69, 0.1); - color: var(--danger); -} -@keyframes hub-toast-in { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Loading */ -.hub-usettings-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 40px; - color: var(--text-muted); - font-size: 14px; -} - -/* Content */ -.hub-usettings-content { - display: flex; - flex-direction: column; - overflow: hidden; - flex: 1; -} - -/* Section tabs */ -.hub-usettings-tabs { - display: flex; - gap: 4px; - padding: 12px 20px 0; - flex-shrink: 0; -} -.hub-usettings-tab { - flex: 1; - padding: 10px 12px; - border: none; - background: var(--bg-secondary); - color: var(--text-muted); - font-family: var(--font); - font-size: 13px; - font-weight: 500; - cursor: pointer; - border-radius: var(--radius) var(--radius) 0 0; - transition: all var(--transition); -} -.hub-usettings-tab:hover { - color: var(--text-normal); - background: var(--bg-tertiary); -} -.hub-usettings-tab.active { - color: var(--accent); - background: rgba(var(--accent-rgb), 0.1); - border-bottom: 2px solid var(--accent); -} - -/* Current sound */ -.hub-usettings-current { - padding: 12px 20px; - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; - background: var(--bg-secondary); - margin: 0 20px; - border-radius: 0 0 var(--radius) var(--radius); - margin-bottom: 12px; -} -.hub-usettings-current-label { - font-size: 13px; - color: var(--text-muted); - flex-shrink: 0; -} -.hub-usettings-current-value { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 500; - color: var(--accent); -} -.hub-usettings-current-none { - font-size: 13px; - color: var(--text-faint); - font-style: italic; -} -.hub-usettings-remove-btn { - background: none; - border: none; - color: var(--text-faint); - cursor: pointer; - font-size: 11px; - padding: 2px 4px; - border-radius: 4px; - transition: all var(--transition); -} -.hub-usettings-remove-btn:hover:not(:disabled) { - color: var(--danger); - background: rgba(237, 66, 69, 0.1); -} - -/* Search */ -.hub-usettings-search-wrap { - position: relative; - padding: 0 20px; - margin-bottom: 12px; - flex-shrink: 0; -} -.hub-usettings-search { - width: 100%; - padding: 8px 32px 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-secondary); - color: var(--text-normal); - font-size: 13px; - font-family: var(--font); - box-sizing: border-box; - transition: border-color var(--transition); -} -.hub-usettings-search:focus { - outline: none; - border-color: var(--accent); -} -.hub-usettings-search-clear { - position: absolute; - right: 28px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--text-faint); - cursor: pointer; - font-size: 12px; - padding: 4px; -} -.hub-usettings-search-clear:hover { - color: var(--text-normal); -} - -/* Sound list */ -.hub-usettings-sounds { - flex: 1; - overflow-y: auto; - padding: 0 20px 16px; - scrollbar-width: thin; - scrollbar-color: var(--bg-tertiary) transparent; -} -.hub-usettings-empty { - text-align: center; - padding: 32px; - color: var(--text-faint); - font-size: 14px; -} - -/* Folder */ -.hub-usettings-folder { - margin-bottom: 12px; -} -.hub-usettings-folder-name { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 4px 0; - margin-bottom: 6px; - border-bottom: 1px solid var(--border); -} -.hub-usettings-folder-sounds { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -/* Sound button */ -.hub-usettings-sound-btn { - display: flex; - align-items: center; - gap: 4px; - padding: 5px 10px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--bg-secondary); - color: var(--text-normal); - font-family: var(--font); - font-size: 12px; - cursor: pointer; - transition: all var(--transition); - white-space: nowrap; -} -.hub-usettings-sound-btn:hover:not(:disabled) { - border-color: var(--accent); - background: rgba(var(--accent-rgb), 0.06); -} -.hub-usettings-sound-btn.selected { - border-color: var(--accent); - background: rgba(var(--accent-rgb), 0.12); - color: var(--accent); -} -.hub-usettings-sound-btn:disabled { - opacity: 0.5; - cursor: wait; -} -.hub-usettings-sound-icon { - font-size: 12px; - line-height: 1; -} -.hub-usettings-sound-name { - max-width: 180px; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Mobile responsive for user settings ── */ -@media (max-width: 600px) { - .hub-usettings-panel { - width: 100%; - max-width: 100vw; - max-height: 100vh; - border-radius: 0; - } - .hub-user-label { - display: none; - } -}