diff --git a/.forgejo/workflows/build-deploy.yml b/.forgejo/workflows/build-deploy.yml new file mode 100644 index 0000000..9cd5b41 --- /dev/null +++ b/.forgejo/workflows/build-deploy.yml @@ -0,0 +1,153 @@ +name: Build & Deploy + +on: + push: + branches: [main, nightly, feature/nightly] + +env: + REGISTRY: forgejo.adriahub.de + REGISTRY_MIRROR: forgejo.daddelolymp.de + IMAGE: root/gaming-hub + +jobs: + build: + runs-on: ubuntu-latest + container: + image: docker:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + steps: + - name: Checkout + run: | + apk add --no-cache git + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "http://root:${{ secrets.PUSH_TOKEN }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" . + + - name: Determine version and tag + id: vars + run: | + VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") + BRANCH="${GITHUB_REF_NAME}" + + if [ "$BRANCH" = "main" ]; then + TAG="main" + CHANNEL="stable" + elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then + TAG="nightly" + VERSION="${VERSION}-nightly" + CHANNEL="nightly" + else + TAG=$(echo "$BRANCH" | sed 's/\//-/g') + VERSION="${VERSION}-dev" + CHANNEL="dev" + fi + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + + - name: Build Docker image + run: | + docker build \ + --build-arg "VITE_BUILD_CHANNEL=${{ steps.vars.outputs.channel }}" \ + --build-arg "VITE_APP_VERSION=${{ steps.vars.outputs.version }}" \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ + . + + if [ "${GITHUB_REF_NAME}" = "main" ]; then + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + fi + + - name: Push to registry (adriahub) + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} + if [ "${GITHUB_REF_NAME}" = "main" ]; then + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + fi + + - name: Mirror to registry (daddelolymp) + run: | + echo "${{ secrets.REGISTRY_DADDELOLYMP_PASSWORD }}" | docker login ${{ env.REGISTRY_MIRROR }} -u root --password-stdin + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ + ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} + docker push ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} + if [ "${GITHUB_REF_NAME}" = "main" ]; then + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest \ + ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:latest + docker push ${{ env.REGISTRY_MIRROR }}/${{ env.IMAGE }}:latest + fi + + deploy: + runs-on: ubuntu-latest + needs: build + if: github.ref_name == 'main' + container: + image: docker:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + steps: + - name: Deploy container + run: | + DEPLOY_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE }}:latest" + CONTAINER_NAME="gaming-hub" + + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin + docker pull "$DEPLOY_IMAGE" + docker stop "$CONTAINER_NAME" || true + docker rm "$CONTAINER_NAME" || true + + docker run -d \ + --name "$CONTAINER_NAME" \ + --network pangolin \ + --restart unless-stopped \ + -p 8085:8080 \ + -e TZ=Europe/Berlin \ + -e NODE_ENV=production \ + -e PORT=8080 \ + -e DATA_DIR=/data \ + -e SOUNDS_DIR=/data/sounds \ + -e "NODE_OPTIONS=--dns-result-order=ipv4first" \ + -e ADMIN_PWD="${{ secrets.GAMING_HUB_ADMIN_PWD }}" \ + -e PCM_CACHE_MAX_MB=2048 \ + -e DISCORD_TOKEN_JUKEBOX="${{ secrets.DISCORD_TOKEN_JUKEBOX }}" \ + -e DISCORD_TOKEN_RADIO="${{ secrets.DISCORD_TOKEN_RADIO }}" \ + -e DISCORD_TOKEN_NOTIFICATIONS="${{ secrets.DISCORD_TOKEN_NOTIFICATIONS }}" \ + -e PUBLIC_URL="${{ secrets.PUBLIC_URL }}" \ + -e STEAM_API_KEY="${{ secrets.STEAM_API_KEY }}" \ + -e DISCORD_CLIENT_ID="${{ secrets.DISCORD_CLIENT_ID }}" \ + -e DISCORD_CLIENT_SECRET="${{ secrets.DISCORD_CLIENT_SECRET }}" \ + -v /mnt/cache/appdata/gaming-hub/data:/data:rw \ + -v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \ + "$DEPLOY_IMAGE" + + docker ps --filter name="$CONTAINER_NAME" + docker image prune -f || true + + bump-version: + runs-on: ubuntu-latest + needs: deploy + if: github.ref_name == 'main' && !contains(github.event.head_commit.message, '[skip ci]') + container: + image: alpine/git:latest + steps: + - name: Checkout and bump + run: | + git clone --branch main --depth 5 \ + "http://root:${{ secrets.PUSH_TOKEN }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" repo + cd repo + git config user.name "Forgejo CI" + git config user.email "ci@adriahub.de" + + VERSION=$(cat VERSION) + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + PATCH=$(echo "$VERSION" | cut -d. -f3) + NEXT_PATCH=$((PATCH + 1)) + NEXT_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}" + + echo "$NEXT_VERSION" > VERSION + git add VERSION + git commit -m "v${NEXT_VERSION} [skip ci]" + git push origin main diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index efd5156..1fa7956 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -173,96 +173,6 @@ 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 53adb84..6476b3a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.2 +1.8.18 diff --git a/electron/main.js b/electron/main.js index 21f33fb..ca46006 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,16 +155,31 @@ h2{font-size:16px;margin-bottom:12px;color:#ccc} .item:hover{border-color:#7c5cff;transform:scale(1.03)} .item img{width:100%;height:120px;object-fit:cover;display:block;background:#111} .item .label{padding:8px 10px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.cancel-row{text-align:center;margin-top:14px} +.bottom-row{display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding:0 4px} +.audio-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none} +.audio-toggle input{display:none} +.switch{width:36px;height:20px;background:#3a3a4e;border-radius:10px;position:relative;transition:background .2s} +.switch::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#888;transition:transform .2s,background .2s} +.audio-toggle input:checked+.switch{background:#7c5cff} +.audio-toggle input:checked+.switch::after{transform:translateX(16px);background:#fff} +.audio-label{font-size:13px;color:#aaa} .cancel-btn{background:#3a3a4e;color:#e0e0e0;border:none;padding:8px 24px;border-radius:6px;cursor:pointer;font-size:14px} .cancel-btn:hover{background:#4a4a5e} -

Bildschirm oder Fenster w\\u00e4hlen

+

Bildschirm oder Fenster wählen

-
+
+ + +
- + +
diff --git a/web/src/AdminPanel.tsx b/web/src/AdminPanel.tsx new file mode 100644 index 0000000..020babd --- /dev/null +++ b/web/src/AdminPanel.tsx @@ -0,0 +1,664 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; + +/* ══════════════════════════════════════════════════════════════════ + TYPES + ══════════════════════════════════════════════════════════════════ */ + +type Sound = { + fileName: string; + name: string; + folder?: string; + relativePath?: string; +}; + +type SoundsResponse = { + items: Sound[]; + total: number; + folders: Array<{ key: string; name: string; count: number }>; +}; + +interface AdminPanelProps { + onClose: () => void; + onLogout: () => void; +} + +/* ══════════════════════════════════════════════════════════════════ + API HELPERS + ══════════════════════════════════════════════════════════════════ */ + +const SB_API = '/api/soundboard'; + +async function fetchAllSounds(): Promise { + 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 a1dc90c..2347765 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,9 @@ import LolstatsTab from './plugins/lolstats/LolstatsTab'; import StreamingTab from './plugins/streaming/StreamingTab'; import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab'; import GameLibraryTab from './plugins/game-library/GameLibraryTab'; +import AdminPanel from './AdminPanel'; +import LoginModal from './LoginModal'; +import UserSettings from './UserSettings'; interface PluginInfo { name: string; @@ -12,6 +15,23 @@ 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, @@ -40,20 +60,18 @@ export default function App() { const [showVersionModal, setShowVersionModal] = useState(false); const [pluginData, setPluginData] = useState>({}); - // Admin state - const [adminLoggedIn, setAdminLoggedIn] = useState(false); - const [showAdminModal, setShowAdminModal] = useState(false); - const [adminPassword, setAdminPassword] = useState(''); - const [adminError, setAdminError] = useState(''); + // ── 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); - // Accent theme state - const [accentTheme, setAccentTheme] = useState(() => { - return localStorage.getItem('gaming-hub-accent') || 'ember'; - }); - - useEffect(() => { - localStorage.setItem('gaming-hub-accent', accentTheme); - }, [accentTheme]); + // Derived state + const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true); + const isDiscordUser = user.authenticated && user.provider === 'discord'; + const isSteamUser = user.authenticated && user.provider === 'steam'; + const isRegularUser = isDiscordUser || isSteamUser; // Electron auto-update state const isElectron = !!(window as any).electronAPI?.isElectron; @@ -69,6 +87,56 @@ export default function App() { } }, []); + // Check auth status + providers on mount + useEffect(() => { + fetch('/api/auth/me', { credentials: 'include' }) + .then(r => r.json()) + .then((data: AuthUser) => setUser(data)) + .catch(() => {}); + + fetch('/api/auth/providers') + .then(r => r.json()) + .then((data: AuthProviders) => setProviders(data)) + .catch(() => {}); + + // Also check legacy admin cookie (backward compat) + fetch('/api/soundboard/admin/status', { credentials: 'include' }) + .then(r => r.json()) + .then(d => { + if (d.authenticated) { + setUser(prev => prev.authenticated ? prev : { authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true }); + } + }) + .catch(() => {}); + }, []); + + // Admin login handler (for LoginModal) + async function handleAdminLogin(password: string): Promise { + 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; @@ -145,60 +213,13 @@ export default function App() { const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev'; - // Close modals on Escape + // Close version modal on Escape useEffect(() => { - if (!showVersionModal && !showAdminModal) return; - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setShowVersionModal(false); - setShowAdminModal(false); - } - }; + if (!showVersionModal) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [showVersionModal, showAdminModal]); - - // Check admin status on mount (cookie-based, survives reload) - useEffect(() => { - fetch('/api/admin/status', { credentials: 'include' }) - .then(r => r.ok ? r.json() : null) - .then(d => { if (d?.authenticated) setAdminLoggedIn(true); }) - .catch(() => {}); - }, []); - - // Admin login handler - const handleAdminLogin = () => { - if (!adminPassword) return; - fetch('/api/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ password: adminPassword }), - }) - .then(r => { - 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); - }); - }; + }, [showVersionModal]); // Tab icon mapping @@ -215,103 +236,91 @@ export default function App() { 'game-library': '\u{1F3AE}', }; - // 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); + // What happens when the user button is clicked + function handleUserButtonClick() { + if (!user.authenticated) { + setShowLoginModal(true); + } else if (isAdmin) { + setShowAdminPanel(true); + } else if (isRegularUser) { + setShowUserSettings(true); + } + } return ( -
- {/* ===== 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()}> @@ -410,13 +383,13 @@ export default function App() { {updateStatus === 'checking' && (
- Suche nach Updates... + Suche nach Updates…
)} {updateStatus === 'downloading' && (
- Update wird heruntergeladen... + Update wird heruntergeladen…
)} {updateStatus === 'ready' && ( @@ -456,46 +429,64 @@ export default function App() {
)} - {/* ===== 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 - /> - - - )} -
-
+ {/* 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.

+
+ ) : ( + /* 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 new file mode 100644 index 0000000..1840ed8 --- /dev/null +++ b/web/src/LoginModal.tsx @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..cb7ce65 --- /dev/null +++ b/web/src/UserSettings.tsx @@ -0,0 +1,257 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface UserInfo { + id: string; + provider: 'discord' | 'steam'; + username: string; + avatar: string | null; + globalName: string | null; +} + +interface SoundOption { + name: string; + fileName: string; + folder: string; + relativePath: string; +} + +interface UserSettingsProps { + user: UserInfo; + onClose: () => void; + onLogout: () => void; +} + +export default function UserSettings({ user, onClose, onLogout }: UserSettingsProps) { + const [entranceSound, setEntranceSound] = useState(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 28fb090..3072cc6 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 = false }: { data: any; isAdmin?: boolean }) { +export default function GameLibraryTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) { // ── State ── const [profiles, setProfiles] = useState([]); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); @@ -109,11 +109,9 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { const filterInputRef = useRef(null); const [filterQuery, setFilterQuery] = useState(''); - // ── Admin state ── - const [showAdmin, setShowAdmin] = useState(false); - const isAdmin = isAdminProp; - const [adminProfiles, setAdminProfiles] = useState([]); - const [adminLoading, setAdminLoading] = useState(false); + // ── Admin (centralized in App.tsx) ── + const _isAdmin = isAdminProp ?? false; + void _isAdmin; // ── SSE data sync ── useEffect(() => { @@ -132,40 +130,6 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { }, []); - // ── Admin: load profiles ── - const loadAdminProfiles = useCallback(async () => { - setAdminLoading(true); - try { - const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' }); - if (resp.ok) { - const d = await resp.json(); - setAdminProfiles(d.profiles || []); - } - } catch { /* silent */ } - finally { setAdminLoading(false); } - }, []); - - // ── Admin: open panel ── - const openAdmin = useCallback(() => { - setShowAdmin(true); - if (isAdmin) loadAdminProfiles(); - }, [isAdmin, loadAdminProfiles]); - - // ── Admin: delete profile ── - const adminDeleteProfile = useCallback(async (profileId: string, displayName: string) => { - if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return; - try { - const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, { - method: 'DELETE', - credentials: 'include', - }); - if (resp.ok) { - loadAdminProfiles(); - fetchProfiles(); - } - } catch { /* silent */ } - }, [loadAdminProfiles, fetchProfiles]); - // ── Steam login ── const connectSteam = useCallback(() => { const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600'); @@ -514,11 +478,6 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { )}
- {isAdmin && ( - - )}
{/* ── Profile Chips ── */} @@ -945,54 +904,6 @@ export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { ); })()} - {/* ── 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 1b791da..d4a9c7f 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -472,29 +472,24 @@ /* ── Empty state ── */ .gl-empty { - flex: 1; display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: 16px; - padding: 40px; height: 100%; + text-align: center; + padding: 60px 20px; } .gl-empty-icon { - 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); } + font-size: 48px; + margin-bottom: 16px; } .gl-empty h3 { - font-size: 26px; font-weight: 700; color: #f2f3f5; - letter-spacing: -0.5px; margin: 0; + color: var(--text-normal); + margin: 0 0 8px; } .gl-empty p { - font-size: 15px; color: #80848e; - text-align: center; max-width: 360px; line-height: 1.5; margin: 0; + color: var(--text-faint); + margin: 0; + font-size: 14px; } /* ── Common game playtime chips ── */ @@ -777,6 +772,7 @@ align-items: center; justify-content: center; z-index: 1000; + backdrop-filter: blur(4px); } .gl-dialog { @@ -806,7 +802,7 @@ padding: 10px 12px; background: #1a1810; border: 1px solid #444; - border-radius: 6px; + border-radius: 8px; color: #fff; font-size: 0.9rem; outline: none; @@ -848,7 +844,7 @@ background: #322d26; color: #ccc; border: none; - border-radius: 6px; + border-radius: 8px; cursor: pointer; font-size: 0.9rem; } @@ -862,7 +858,7 @@ background: #a855f7; color: #fff; border: none; - border-radius: 6px; + border-radius: 8px; cursor: pointer; font-size: 0.9rem; font-weight: 600; @@ -960,7 +956,7 @@ color: #fff; border: none; padding: 10px 20px; - border-radius: 6px; + border-radius: 8px; 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 efa1a28..2bda0a2 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: 4px; + border-radius: 8px; 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: 4px; + border-radius: 8px; 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: 4px; + border-radius: 8px; 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: 4px; + border-radius: 8px; 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: 4px; + border-radius: 8px; background: var(--bg-secondary); min-width: 180px; flex-shrink: 0; @@ -268,7 +268,7 @@ align-items: center; gap: 10px; padding: 10px 12px; - border-radius: 4px; + border-radius: 8px; 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: 4px; + border-radius: 8px; padding: 8px; margin-top: 4px; margin-bottom: 4px; @@ -447,7 +447,7 @@ .lol-error { padding: 16px; - border-radius: 4px; + border-radius: 8px; background: rgba(231,76,60,0.1); color: #e74c3c; font-size: 13px; @@ -456,25 +456,22 @@ } .lol-empty { - flex: 1; display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: 16px; - padding: 40px; height: 100%; + text-align: center; + padding: 60px 20px; + color: var(--text-faint); } .lol-empty-icon { - 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); } + font-size: 48px; + margin-bottom: 12px; } .lol-empty h3 { - font-size: 26px; font-weight: 700; color: var(--text-normal); - letter-spacing: -0.5px; margin: 0; + margin: 0 0 8px; + color: var(--text-muted); + font-size: 16px; } .lol-empty p { - font-size: 15px; color: var(--text-muted); - text-align: center; max-width: 360px; line-height: 1.5; margin: 0; + margin: 0; + font-size: 13px; } /* ── Load more ── */ @@ -484,7 +481,7 @@ padding: 10px; margin-top: 8px; border: 1px solid var(--bg-tertiary); - border-radius: 4px; + border-radius: 8px; background: transparent; color: var(--text-muted); font-size: 13px; @@ -539,7 +536,7 @@ .lol-tier-filter { padding: 6px 12px; border: 1px solid var(--bg-tertiary); - border-radius: 4px; + border-radius: 8px; 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 434f0f2..a58c165 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -186,7 +186,6 @@ 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', @@ -267,6 +266,14 @@ 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', @@ -305,7 +312,7 @@ interface SoundboardTabProps { COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: SoundboardTabProps) { +export default function SoundboardTab({ data, isAdmin: isAdminProp }: SoundboardTabProps) { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); @@ -353,14 +360,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So const volDebounceRef = useRef>(undefined); /* ── Admin ── */ - 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(''); + const isAdmin = isAdminProp ?? false; /* ── Drag & Drop Upload ── */ const [isDragging, setIsDragging] = useState(false); @@ -500,7 +500,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - /* ── Theme (persist — global theming now handled by app-shell) ── */ + /* ── Theme (persist only, data-theme is set on .sb-app div) ── */ useEffect(() => { localStorage.setItem('jb-theme', theme); }, [theme]); @@ -629,13 +629,6 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So 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 { @@ -794,65 +787,6 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So 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]); @@ -890,26 +824,6 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So 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; @@ -920,119 +834,122 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So RENDER ════════════════════════════════════════════ */ return ( -
+
{chaosMode &&
} - {/* ═══ CONTENT HEADER ═══ */} -
-
- Soundboard - {totalSoundsDisplay} -
+ {/* ═══ TOPBAR ═══ */} +
+
+
+ music_note +
+ Soundboard -
- search - setQuery(e.target.value)} - /> - {query && ( - - )} + {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 +
+ )} +
+ )} +
-
- {/* Now Playing indicator */} +
+
{clockMain}{clockSec}
+
+ +
{lastPlayed && (
- Now: {lastPlayed} + Last Played: {lastPlayed}
)} - - {/* Connection status */} {selected && ( -
setShowConnModal(true)} - style={{ cursor: 'pointer' }} - title="Verbindungsdetails" - > - +
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> + Verbunden {voiceStats?.voicePing != null && ( - {voiceStats.voicePing}ms + {voiceStats.voicePing}ms )}
)} - - {/* Admin button */} - {isAdmin && ( - - )} - - {/* Playback controls */} -
- - - -
-
+
{/* ═══ TOOLBAR ═══ */}
- {/* Filter tabs */} - - - +
+ + + +
-
+
+ search + setQuery(e.target.value)} + /> + {query && ( + + )} +
- {/* URL import */}
{getUrlType(importUrl) === 'youtube' ? 'smart_display' @@ -1065,119 +982,112 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
-
- {/* 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} - /> - {Math.round(volume * 100)}% -
+
- {/* 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 -
- )} -
- )} -
+
+ { + 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)}% +
- {/* Card size slider */} -
- grid_view - setCardSize(parseInt(e.target.value))} + + + + + + +
+ grid_view + setCardSize(parseInt(e.target.value))} + /> +
+ +
+ {THEMES.map(t => ( +
setTheme(t.id)} /> -
+ ))}
- {/* ═══ 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} -
- ))} +
+
+ library_music +
+ Sounds gesamt + {totalSoundsDisplay}
- )} + +
+ leaderboard +
+ Most Played +
+ {analyticsTop.length === 0 ? ( + Noch keine Plays + ) : ( + analyticsTop.map((item, idx) => ( + + {idx + 1}. {item.name} ({item.count}) + + )) + )} +
+
+
+
{/* ═══ FOLDER CHIPS ═══ */} {activeTab === 'all' && visibleFolders.length > 0 && ( @@ -1201,8 +1111,8 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
)} - {/* ═══ SOUND GRID ═══ */} -
+ {/* ═══ MAIN ═══ */} +
{displaySounds.length === 0 ? (
{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}
@@ -1219,88 +1129,66 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So : 'Hier gibt\'s noch nichts zu hoeren.'}
- ) : (() => { - // 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 }); - }); + ) : ( +
+ {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)'; - 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}} -
-
-
-
-
- ); - })} -
- - )); - })()} -
+ 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 && ( @@ -1327,7 +1215,14 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
{ const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; - await deleteAdminPaths([path]); + if (!window.confirm(`Sound "${ctxMenu.sound.name}" loeschen?`)) { setCtxMenu(null); return; } + try { + await apiAdminDelete([path]); + notify('Sound geloescht'); + setRefreshKey(k => k + 1); + } catch (e: any) { + notify(e?.message || 'Loeschen fehlgeschlagen', 'error'); + } setCtxMenu(null); }}> delete @@ -1408,142 +1303,6 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So
)} - {/* ═══ 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 && (
@@ -1667,7 +1426,7 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So {dropPhase === 'naming' && (
)} - {isAdmin && ( - - )}
{streams.length === 0 && !isBroadcasting ? ( @@ -881,80 +801,6 @@ export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { d
)} - {/* ── 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 bb90670..abdf17a 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -373,25 +373,23 @@ /* ── Empty state ── */ .stream-empty { - flex: 1; display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: 16px; - padding: 40px; height: 100%; + text-align: center; + padding: 60px 20px; + color: var(--text-muted); } .stream-empty-icon { - 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); } + font-size: 48px; + margin-bottom: 12px; + opacity: 0.4; } .stream-empty h3 { - font-size: 26px; font-weight: 700; color: #f2f3f5; - letter-spacing: -0.5px; margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-normal); + margin-bottom: 6px; } .stream-empty p { - font-size: 15px; color: #80848e; - text-align: center; max-width: 360px; line-height: 1.5; margin: 0; + font-size: 14px; } /* ── Error ── */ diff --git a/web/src/plugins/watch-together/watch-together.css b/web/src/plugins/watch-together/watch-together.css index 95111c5..32684b4 100644 --- a/web/src/plugins/watch-together/watch-together.css +++ b/web/src/plugins/watch-together/watch-together.css @@ -161,25 +161,23 @@ /* ── Empty state ── */ .wt-empty { - flex: 1; display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: 16px; - padding: 40px; height: 100%; + text-align: center; + padding: 60px 20px; + color: var(--text-muted); } .wt-empty-icon { - 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); } + font-size: 48px; + margin-bottom: 12px; + opacity: 0.4; } .wt-empty h3 { - font-size: 26px; font-weight: 700; color: #f2f3f5; - letter-spacing: -0.5px; margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-normal); + margin-bottom: 6px; } .wt-empty p { - font-size: 15px; color: #80848e; - text-align: center; max-width: 360px; line-height: 1.5; margin: 0; + font-size: 14px; } /* ── Error ── */ @@ -558,7 +556,7 @@ .wt-quality-select { background: var(--bg-secondary, #2a2620); color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #3a352d); + border: 1px solid var(--border-color, #322d26); border-radius: 6px; padding: 2px 6px; font-size: 12px; diff --git a/web/src/styles.css b/web/src/styles.css index 6319c92..4d5c046 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,169 +1,38 @@ -/* ============================================================ - GAMING HUB -- Global Styles - Design System v3.0 -- CI Redesign (warm brown, DM Sans) - ============================================================ */ - -/* -- Google Fonts ------------------------------------------- */ +/* ── 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'); -/* ============================================================ - A) CSS CUSTOM PROPERTIES - ============================================================ */ +/* ── CSS Variables ── */ :root { - /* -- Surface Palette (warm brown) ------------------------- */ - --bg-deepest: #141209; - --bg-deep: #1a1810; + --bg-deep: #1a1810; --bg-primary: #211e17; --bg-secondary: #2a2620; --bg-tertiary: #322d26; - --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; - --warning: #fee75c; - --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; -} - - -/* ============================================================ - B) ACCENT THEME SYSTEM - ============================================================ */ - -/* Default: Ember (orange) */ -:root, -[data-accent="ember"] { - --accent: #e67e22; + --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-soft: rgba(230, 126, 34, 0.15); - --accent-text: #f0a050; + --accent-dim: rgba(230, 126, 34, 0.15); + --accent-border: rgba(230, 126, 34, 0.35); + --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; } -[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 - ============================================================ */ +/* ── Reset & Base ── */ *, *::before, *::after { @@ -172,828 +41,506 @@ box-sizing: border-box; } -html { - scroll-behavior: smooth; -} - html, body { height: 100%; - overflow: hidden; - font-family: var(--font-body); - font-size: var(--text-base); - color: var(--text-primary); - background: var(--bg-primary); + font-family: var(--font); + font-size: 13px; + color: var(--text-normal); + background: var(--bg-deep); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow: hidden; } #root { height: 100%; } -/* -- 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 { +/* ── App Shell ── */ +.hub-app { display: flex; + flex-direction: column; height: 100vh; - width: 100vw; overflow: hidden; } -/* -- Sidebar ------------------------------------------------ */ -.app-sidebar { - width: var(--sidebar-nav-w); - min-width: var(--sidebar-nav-w); - background: var(--bg-deep); - display: flex; - flex-direction: column; - border-right: 1px solid var(--border-subtle); - z-index: 15; -} - -.sidebar-header { - height: var(--header-h); +/* ── Header ── */ +.hub-header { + position: sticky; + top: 0; + z-index: 100; display: flex; align-items: center; - padding: 0 var(--space-3); - border-bottom: 1px solid var(--border-subtle); - gap: var(--space-2); - flex-shrink: 0; -} - -.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; -} - -.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; -} - -.sidebar-nav { - flex: 1; - overflow-y: auto; - padding: var(--space-2); -} - -.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; + height: var(--header-height); + min-height: var(--header-height); + padding: 0 16px; background: var(--bg-primary); - position: relative; + border-bottom: 1px solid var(--border); + gap: 16px; } -/* -- Content Header ----------------------------------------- */ -.content-header { - height: var(--header-h); - min-height: var(--header-h); +.hub-header-left { 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; + gap: 10px; flex-shrink: 0; } -.content-header__title { - font-family: var(--font-display); - font-size: var(--text-lg); - font-weight: var(--weight-semibold); +.hub-logo { + font-size: 24px; + line-height: 1; +} + +.hub-title { + font-size: 18px; + font-weight: 700; + color: var(--text-normal); 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; +/* ── Connection Status Dot ── */ +.hub-conn-dot { + width: 10px; + height: 10px; border-radius: 50%; - cursor: pointer; - border: 2px solid transparent; - transition: all var(--duration-fast); + background: var(--danger); + flex-shrink: 0; + transition: background var(--transition); + box-shadow: 0 0 0 2px rgba(237, 66, 69, 0.25); } -.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%; +.hub-conn-dot.online { 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% { 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); } + 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); + } } -/* -- Volume Control ----------------------------------------- */ -.volume-control { +/* ── Tab Navigation ── */ +.hub-tabs { display: flex; align-items: center; - gap: var(--space-2); - color: var(--text-tertiary); + 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 { font-size: 16px; + line-height: 1; } -.volume-slider { - -webkit-appearance: none; - appearance: none; - width: 80px; - height: 4px; - border-radius: 2px; - background: var(--bg-elevated); - outline: none; +.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); 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; } -.volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--accent); +.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; cursor: pointer; - border: none; - /* no glow */ + 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; } -.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, +/* ── Update Modal ── */ .hub-update-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, .7); - /* no blur */ - z-index: 1000; + z-index: 9999; display: flex; align-items: center; justify-content: center; - animation: modal-fade-in var(--duration-normal) ease; + 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; } -@keyframes modal-fade-in { - from { opacity: 0; } - to { opacity: 1; } +/* ── 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-slide-in { - from { opacity: 0; transform: scale(0.95) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } +/* ── 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; } -/* -- Version Info Modal ------------------------------------- */ +/* ── 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 ── */ .hub-version-clickable { cursor: pointer; - transition: all var(--duration-fast); + transition: all var(--transition); padding: 2px 8px; - border-radius: var(--radius-sm); + border-radius: var(--radius); } - .hub-version-clickable:hover { color: var(--accent); - background: var(--accent-soft); + 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); } - .hub-version-modal { - background: var(--bg-secondary); - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 4px; width: 340px; - box-shadow: var(--shadow-xl); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); overflow: hidden; - animation: modal-slide-in var(--duration-normal) ease; + 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); } } - .hub-version-modal-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--border-subtle); - font-weight: var(--weight-bold); - font-size: var(--text-base); + padding: 14px 16px; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 14px; } - .hub-version-modal-close { background: none; border: none; - color: var(--text-tertiary); + color: var(--text-muted); cursor: pointer; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - font-size: var(--text-base); - transition: all var(--duration-fast); + padding: 4px 8px; + border-radius: 6px; + font-size: 14px; + transition: all var(--transition); } - .hub-version-modal-close:hover { - background: var(--surface-glass-hover); - color: var(--text-primary); + background: rgba(255, 255, 255, 0.08); + color: var(--text-normal); } - .hub-version-modal-body { - padding: var(--space-4); + padding: 16px; display: flex; flex-direction: column; - gap: var(--space-3); + gap: 12px; } - .hub-version-modal-row { display: flex; justify-content: space-between; align-items: center; } - .hub-version-modal-label { - color: var(--text-secondary); - font-size: var(--text-sm); + color: var(--text-muted); + font-size: 13px; } - .hub-version-modal-value { - font-weight: var(--weight-semibold); - font-size: var(--text-sm); + font-weight: 600; + font-size: 13px; display: flex; align-items: center; - gap: var(--space-2); + gap: 6px; } - .hub-version-modal-dot { width: 8px; height: 8px; @@ -1001,629 +548,131 @@ 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: var(--weight-medium); - font-size: var(--text-sm); + font-weight: 500; + font-size: 13px; } - .hub-version-modal-link:hover { text-decoration: underline; } - .hub-version-modal-hint { - font-size: var(--text-xs); + font-size: 11px; color: var(--accent); - padding: var(--space-2) var(--space-3); - background: var(--accent-soft); - border-radius: var(--radius-sm); + padding: 6px 10px; + background: rgba(230, 126, 34, 0.1); + border-radius: var(--radius); text-align: center; } -/* -- Update Section in Version Modal ------------------------ */ +/* ── Update Section in Version Modal ── */ .hub-version-modal-update { - margin-top: var(--space-1); - padding-top: var(--space-3); - border-top: 1px solid var(--border-subtle); + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--border); } - .hub-version-modal-update-btn { width: 100%; - padding: var(--space-3) var(--space-4); + padding: 10px 16px; border: none; - border-radius: var(--radius-sm); + border-radius: var(--radius); background: var(--bg-tertiary); - color: var(--text-primary); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); + color: var(--text-normal); + font-size: 13px; + font-weight: 600; cursor: pointer; - transition: all var(--duration-fast); + transition: all var(--transition); display: flex; align-items: center; justify-content: center; - gap: var(--space-2); - font-family: var(--font-body); + gap: 8px; } - .hub-version-modal-update-btn:hover { background: var(--bg-hover); color: var(--accent); } - .hub-version-modal-update-btn.ready { - background: rgba(87, 210, 143, .15); - color: var(--success); + background: rgba(46, 204, 113, 0.15); + color: #2ecc71; } - .hub-version-modal-update-btn.ready:hover { - background: rgba(87, 210, 143, .25); + background: rgba(46, 204, 113, 0.25); } - .hub-version-modal-update-status { display: flex; align-items: center; justify-content: center; - gap: var(--space-2); - font-size: var(--text-sm); - color: var(--text-secondary); - padding: var(--space-2) 0; + gap: 8px; + font-size: 13px; + color: var(--text-muted); + padding: 8px 0; flex-wrap: wrap; } - .hub-version-modal-update-status.success { - color: var(--success); + color: #2ecc71; } - .hub-version-modal-update-status.error { - color: var(--danger); + color: #e74c3c; } - .hub-version-modal-update-retry { background: none; border: none; - color: var(--text-secondary); - font-size: var(--text-xs); + color: var(--text-muted); + font-size: 11px; cursor: pointer; text-decoration: underline; padding: 2px 4px; width: 100%; - margin-top: var(--space-1); - font-family: var(--font-body); + margin-top: 4px; } - .hub-version-modal-update-retry:hover { - color: var(--text-primary); + color: var(--text-normal); } - -/* -- 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 { +@keyframes hub-spin { to { transform: rotate(360deg); } } - .hub-update-spinner { width: 14px; height: 14px; - border: 2px solid var(--border-default); + border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; - animation: spin 0.8s linear infinite; + animation: hub-spin 0.8s linear infinite; flex-shrink: 0; } -/* -- 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); - 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-admin-modal-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); -} - -.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); -} - -.btn--ghost:hover { - background: var(--surface-glass); - color: var(--text-primary); -} - -.btn--danger { - background: var(--danger); - color: #fff; -} - -.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 { +/* ── Main Content Area ── */ +.hub-content { flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: var(--bg-deep); + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; } -.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); +.hub-content::-webkit-scrollbar { + width: 6px; } -.toast__close:hover { - color: var(--text-primary); +.hub-content::-webkit-scrollbar-track { + background: transparent; } -@keyframes toast-in { - from { opacity: 0; transform: translateX(20px); } - to { opacity: 1; transform: translateX(0); } +.hub-content::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 3px; } -@keyframes toast-out { - from { opacity: 1; transform: translateX(0); } - to { opacity: 0; transform: translateX(20px); } +.hub-content::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); } - -/* Noise texture removed per CI v2 */ - - -/* ============================================================ - PRESERVED: Empty State - ============================================================ */ +/* ── Empty State ── */ .hub-empty { display: flex; flex-direction: column; @@ -1632,84 +681,124 @@ html, body { height: 100%; min-height: 300px; text-align: center; - padding: var(--space-7); + padding: 32px; animation: fade-in 300ms ease; } .hub-empty-icon { font-size: 64px; line-height: 1; - margin-bottom: var(--space-5); + margin-bottom: 20px; opacity: 0.6; filter: grayscale(30%); } .hub-empty h2 { - font-size: var(--text-xl); - font-weight: var(--weight-bold); - color: var(--text-primary); - margin-bottom: var(--space-2); + font-size: 22px; + font-weight: 700; + color: var(--text-normal); + margin-bottom: 8px; } .hub-empty p { - font-size: var(--text-md); - color: var(--text-secondary); + font-size: 15px; + color: var(--text-muted); max-width: 360px; line-height: 1.5; } -/* -- 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); +/* ── Animations ── */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } -/* -- 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; +/* ── Selection ── */ +::selection { + background: rgba(var(--accent-rgb), 0.3); + color: var(--text-normal); } -.hub-download-btn:hover { - color: var(--accent); - background: var(--accent-soft); +/* ── Focus Styles ── */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } -.hub-download-icon { - font-size: var(--text-base); - line-height: 1; +/* ── 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-label { - line-height: 1; +@media (max-width: 480px) { + .hub-header-right { + display: none; + } + + .hub-header { + padding: 0 8px; + gap: 6px; + } + + .hub-title { + font-size: 14px; + } } - -/* ============================================================ - PRESERVED: Radio Plugin Styles - ============================================================ */ +/* ══════════════════════════════════════════════ + RADIO PLUGIN – World Radio Globe + ══════════════════════════════════════════════ */ .radio-container { display: flex; @@ -1718,62 +807,80 @@ 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: #16131c; - --bg-primary: #1d1926; - --bg-secondary: #272235; - --bg-tertiary: #312b42; - --accent: #9b59b6; + --bg-deep: #13111c; + --bg-primary: #1a1726; + --bg-secondary: #241f35; + --bg-tertiary: #2e2845; + --accent: #9b59b6; + --accent-rgb: 155, 89, 182; --accent-hover: #8e44ad; } .radio-container[data-theme="forest"] { - --bg-deep: #121a14; - --bg-primary: #172119; - --bg-secondary: #1f2e22; - --bg-tertiary: #283a2c; - --accent: #2ecc71; + --bg-deep: #0f1a14; + --bg-primary: #142119; + --bg-secondary: #1c2e22; + --bg-tertiary: #253a2c; + --accent: #2ecc71; + --accent-rgb: 46, 204, 113; --accent-hover: #27ae60; } .radio-container[data-theme="ocean"] { - --bg-deep: #101620; - --bg-primary: #151e2c; - --bg-secondary: #1c2a38; - --bg-tertiary: #243646; - --accent: #3498db; + --bg-deep: #0a1628; + --bg-primary: #0f1e33; + --bg-secondary: #162a42; + --bg-tertiary: #1e3652; + --accent: #3498db; + --accent-rgb: 52, 152, 219; --accent-hover: #2980b9; } .radio-container[data-theme="cherry"] { - --bg-deep: #1a1014; - --bg-primary: #22151a; - --bg-secondary: #301e25; - --bg-tertiary: #3e2830; - --accent: #e74c6f; + --bg-deep: #1a0f14; + --bg-primary: #22141a; + --bg-secondary: #301c25; + --bg-tertiary: #3e2530; + --accent: #e74c6f; + --accent-rgb: 231, 76, 111; --accent-hover: #c0392b; } -/* -- Radio Topbar ------------------------------------------- */ +/* ── Globe ── */ +/* ── Radio Topbar ── */ .radio-topbar { display: flex; align-items: center; - padding: 0 var(--space-4); - height: var(--header-h); - background: var(--bg-secondary); + padding: 0 16px; + height: 52px; + background: var(--bg-secondary, #2a2620); border-bottom: 1px solid rgba(0, 0, 0, .24); z-index: 10; flex-shrink: 0; - gap: var(--space-4); + gap: 16px; } .radio-topbar-left { display: flex; align-items: center; - gap: var(--space-3); + gap: 10px; flex-shrink: 0; } @@ -1782,17 +889,17 @@ html, body { } .radio-topbar-title { - font-size: var(--text-lg); - font-weight: var(--weight-bold); - color: var(--text-primary); - letter-spacing: -0.02em; + font-size: 16px; + font-weight: 700; + color: var(--text-normal); + letter-spacing: -.02em; } .radio-topbar-np { flex: 1; display: flex; align-items: center; - gap: var(--space-3); + gap: 10px; min-width: 0; justify-content: center; } @@ -1800,7 +907,7 @@ html, body { .radio-topbar-right { display: flex; align-items: center; - gap: var(--space-2); + gap: 6px; flex-shrink: 0; margin-left: auto; } @@ -1808,17 +915,17 @@ html, body { .radio-topbar-stop { display: flex; align-items: center; - gap: var(--space-1); + gap: 4px; background: var(--danger); color: #fff; border: none; - 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); + border-radius: var(--radius); + padding: 6px 14px; + font-size: 13px; + font-family: var(--font); + font-weight: 600; cursor: pointer; - transition: all var(--duration-fast); + transition: all var(--transition); flex-shrink: 0; } @@ -1829,11 +936,11 @@ html, body { .radio-theme-inline { display: flex; align-items: center; - gap: var(--space-1); - margin-left: var(--space-1); + gap: 4px; + margin-left: 4px; } -/* -- Globe -------------------------------------------------- */ +/* ── Globe Wrapper ── */ .radio-globe-wrap { position: relative; flex: 1; @@ -1849,10 +956,10 @@ html, body { outline: none !important; } -/* -- Radio Search ------------------------------------------- */ +/* ── Search Overlay ── */ .radio-search { position: absolute; - top: var(--space-4); + top: 16px; left: 50%; transform: translateX(-50%); z-index: 20; @@ -1863,12 +970,11 @@ html, body { display: flex; align-items: center; background: rgba(33, 30, 23, 0.92); - /* no blur */ - border: 1px solid var(--border-default); + border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 0 var(--space-3); - gap: var(--space-2); - box-shadow: var(--shadow-lg); + padding: 0 14px; + gap: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); } .radio-search-icon { @@ -1881,59 +987,60 @@ html, body { flex: 1; background: transparent; border: none; - color: var(--text-primary); - font-family: var(--font-body); - font-size: var(--text-base); - padding: var(--space-3) 0; + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; + padding: 12px 0; outline: none; } .radio-search-input::placeholder { - color: var(--text-tertiary); + color: var(--text-faint); } .radio-search-clear { background: none; border: none; - color: var(--text-secondary); - font-size: var(--text-base); + color: var(--text-muted); + font-size: 14px; cursor: pointer; - padding: var(--space-1); - border-radius: var(--radius-xs); - transition: color var(--duration-fast); + padding: 4px; + border-radius: 4px; + transition: color var(--transition); } .radio-search-clear:hover { - color: var(--text-primary); + color: var(--text-normal); } -/* -- Radio Search Results ----------------------------------- */ +/* ── Search Results ── */ .radio-search-results { - margin-top: var(--space-2); + margin-top: 6px; background: rgba(33, 30, 23, 0.95); - /* no blur */ - border: 1px solid var(--border-default); + border: 1px solid var(--border); border-radius: var(--radius-lg); max-height: 360px; overflow-y: auto; - box-shadow: var(--shadow-xl); + box-shadow: 0 12px 40px rgba(0,0,0,0.5); + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; } .radio-search-result { display: flex; align-items: center; - gap: var(--space-3); + gap: 10px; width: 100%; - padding: var(--space-3) var(--space-3); + padding: 10px 14px; background: none; border: none; - border-bottom: 1px solid var(--border-subtle); - color: var(--text-primary); - font-family: var(--font-body); - font-size: var(--text-base); + border-bottom: 1px solid var(--border); + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; cursor: pointer; text-align: left; - transition: background var(--duration-fast); + transition: background var(--transition); } .radio-search-result:last-child { @@ -1941,11 +1048,11 @@ html, body { } .radio-search-result:hover { - background: var(--accent-soft); + background: rgba(var(--accent-rgb), 0.08); } .radio-search-result-icon { - font-size: var(--text-lg); + font-size: 18px; flex-shrink: 0; } @@ -1957,59 +1064,58 @@ html, body { } .radio-search-result-title { - font-weight: var(--weight-semibold); + font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .radio-search-result-sub { - font-size: var(--text-sm); - color: var(--text-secondary); + font-size: 12px; + color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* -- Favorites FAB ------------------------------------------ */ +/* ── Favorites FAB ── */ .radio-fab { position: absolute; - top: var(--space-4); - right: var(--space-4); + top: 16px; + right: 16px; z-index: 20; display: flex; align-items: center; - gap: var(--space-1); - padding: var(--space-3) var(--space-3); + gap: 4px; + padding: 10px 14px; background: rgba(33, 30, 23, 0.92); - /* no blur */ - border: 1px solid var(--border-default); + border: 1px solid var(--border); border-radius: var(--radius-lg); - color: var(--text-primary); + color: var(--text-normal); font-size: 16px; cursor: pointer; - box-shadow: var(--shadow-lg); - transition: all var(--duration-fast); + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + transition: all var(--transition); } .radio-fab:hover, .radio-fab.active { - background: var(--accent-soft); - border-color: var(--accent); + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.3); } .radio-fab-badge { - font-size: var(--text-xs); - font-weight: var(--weight-bold); + font-size: 11px; + font-weight: 700; background: var(--accent); color: #fff; padding: 1px 6px; - border-radius: var(--radius-full); + border-radius: 6px; min-width: 18px; text-align: center; } -/* -- Side Panel --------------------------------------------- */ +/* ── Side Panel ── */ .radio-panel { position: absolute; top: 0; @@ -2018,12 +1124,11 @@ html, body { height: 100%; z-index: 15; background: rgba(33, 30, 23, 0.95); - /* no blur */ - border-left: 1px solid var(--border-default); + border-left: 1px solid var(--border); display: flex; flex-direction: column; - animation: slide-in-right var(--duration-normal) ease; - box-shadow: -8px 0 32px rgba(0, 0, 0, .3); + animation: slide-in-right 200ms ease; + box-shadow: -8px 0 32px rgba(0,0,0,0.3); } @keyframes slide-in-right { @@ -2035,20 +1140,20 @@ html, body { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-4); - border-bottom: 1px solid var(--border-subtle); + padding: 16px; + border-bottom: 1px solid var(--border); flex-shrink: 0; } .radio-panel-header h3 { - font-size: var(--text-lg); - font-weight: var(--weight-bold); - color: var(--text-primary); + font-size: 16px; + font-weight: 700; + color: var(--text-normal); } .radio-panel-sub { - font-size: var(--text-sm); - color: var(--text-secondary); + font-size: 12px; + color: var(--text-muted); display: block; margin-top: 2px; } @@ -2056,60 +1161,62 @@ html, body { .radio-panel-close { background: none; border: none; - color: var(--text-secondary); - font-size: var(--text-lg); + color: var(--text-muted); + font-size: 18px; cursor: pointer; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-xs); - transition: all var(--duration-fast); + padding: 4px 8px; + border-radius: 4px; + transition: all var(--transition); } .radio-panel-close:hover { - color: var(--text-primary); - background: var(--surface-glass-hover); + color: var(--text-normal); + background: var(--bg-secondary); } .radio-panel-body { flex: 1; overflow-y: auto; - padding: var(--space-2); + padding: 8px; + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; } .radio-panel-empty { text-align: center; - color: var(--text-secondary); - padding: var(--space-8) var(--space-4); - font-size: var(--text-base); + color: var(--text-muted); + padding: 40px 16px; + font-size: 14px; } .radio-panel-loading { display: flex; flex-direction: column; align-items: center; - gap: var(--space-3); - padding: var(--space-8) var(--space-4); - color: var(--text-secondary); - font-size: var(--text-base); + gap: 12px; + padding: 40px 16px; + color: var(--text-muted); + font-size: 14px; } -/* -- Station Card ------------------------------------------- */ +/* ── Station Card ── */ .radio-station { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-3) var(--space-3); - border-radius: var(--radius-sm); - transition: background var(--duration-fast); - gap: var(--space-3); + padding: 10px 12px; + border-radius: var(--radius); + transition: background var(--transition); + gap: 10px; } .radio-station:hover { - background: var(--surface-glass-hover); + background: var(--bg-secondary); } .radio-station.playing { - background: var(--accent-soft); - border: 1px solid var(--accent); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); } .radio-station-info { @@ -2121,17 +1228,17 @@ html, body { } .radio-station-name { - font-size: var(--text-base); - font-weight: var(--weight-semibold); - color: var(--text-primary); + font-size: 14px; + font-weight: 600; + color: var(--text-normal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .radio-station-loc { - font-size: var(--text-xs); - color: var(--text-tertiary); + font-size: 11px; + color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -2140,31 +1247,31 @@ html, body { .radio-station-live { display: flex; align-items: center; - gap: var(--space-2); - font-size: var(--text-xs); + gap: 6px; + font-size: 11px; color: var(--accent); - font-weight: var(--weight-semibold); + font-weight: 600; } .radio-station-btns { display: flex; - gap: var(--space-1); + gap: 4px; flex-shrink: 0; } -/* -- Radio Buttons ------------------------------------------ */ +/* ── Buttons ── */ .radio-btn-play, .radio-btn-stop { width: 34px; height: 34px; border: none; border-radius: 50%; - font-size: var(--text-base); + font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all var(--duration-fast); + transition: all var(--transition); } .radio-btn-play { @@ -2199,23 +1306,23 @@ html, body { font-size: 16px; cursor: pointer; background: transparent; - color: var(--text-tertiary); + color: var(--text-faint); display: flex; align-items: center; justify-content: center; - transition: all var(--duration-fast); + transition: all var(--transition); } .radio-btn-fav:hover { color: var(--warning); - background: rgba(250, 166, 26, .1); + background: rgba(254, 231, 92, 0.1); } .radio-btn-fav.active { color: var(--warning); } -/* -- Equalizer Animation ------------------------------------ */ +/* ── Equalizer Animation ── */ .radio-eq { display: flex; align-items: flex-end; @@ -2230,23 +1337,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-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); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-normal); + font-family: var(--font); + font-size: 13px; + padding: 6px 10px; cursor: pointer; outline: none; max-width: 180px; @@ -2269,27 +1376,28 @@ html, body { } .radio-np-name { - font-size: var(--text-base); - font-weight: var(--weight-semibold); - color: var(--text-primary); + font-size: 14px; + font-weight: 600; + color: var(--text-normal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .radio-np-loc { - font-size: var(--text-xs); - color: var(--text-secondary); + font-size: 11px; + color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* -- Radio Volume ------------------------------------------- */ + +/* ── Volume Slider ── */ .radio-volume { display: flex; align-items: center; - gap: var(--space-2); + gap: 6px; flex-shrink: 0; } @@ -2306,7 +1414,7 @@ html, body { width: 100px; height: 4px; border-radius: 2px; - background: var(--bg-tertiary); + background: var(--bg-tertiary, #3a352d); outline: none; cursor: pointer; } @@ -2317,25 +1425,25 @@ html, body { width: 14px; height: 14px; border-radius: 50%; - background: var(--accent); + background: var(--accent, #e67e22); cursor: pointer; border: none; - box-shadow: 0 0 4px rgba(0, 0, 0, .3); + box-shadow: 0 0 4px rgba(0,0,0,0.3); } .radio-volume-slider::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; - background: var(--accent); + background: var(--accent, #e67e22); cursor: pointer; border: none; - box-shadow: 0 0 4px rgba(0, 0, 0, .3); + box-shadow: 0 0 4px rgba(0,0,0,0.3); } .radio-volume-val { - font-size: var(--text-xs); - color: var(--text-secondary); + font-size: 11px; + color: var(--text-muted); min-width: 32px; text-align: right; } @@ -2355,43 +1463,42 @@ html, body { .radio-theme-dot.active { border-color: #fff; - box-shadow: 0 0 6px rgba(255, 255, 255, .3); } -/* -- Radio Overlays ----------------------------------------- */ +/* ── Station count ── */ .radio-counter { position: absolute; - bottom: var(--space-4); - left: var(--space-4); + bottom: 16px; + left: 16px; z-index: 10; - font-size: var(--text-sm); - color: var(--text-tertiary); + font-size: 12px; + color: var(--text-faint); background: rgba(33, 30, 23, 0.8); - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-full); + padding: 4px 10px; + border-radius: 20px; pointer-events: none; } .radio-attribution { position: absolute; - right: var(--space-4); - bottom: var(--space-4); + right: 16px; + bottom: 16px; z-index: 10; - font-size: var(--text-sm); - color: var(--text-tertiary); + font-size: 12px; + color: var(--text-faint); background: rgba(33, 30, 23, 0.8); - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-full); + padding: 4px 10px; + border-radius: 20px; text-decoration: none; - transition: color var(--duration-fast), background var(--duration-fast); + transition: color var(--transition), background var(--transition); } .radio-attribution:hover { - color: var(--text-primary); + color: var(--text-normal); background: rgba(33, 30, 23, 0.92); } -/* -- Radio Spinner ------------------------------------------ */ +/* ── Spinner ── */ .radio-spinner { width: 24px; height: 24px; @@ -2401,223 +1508,32 @@ html, body { animation: spin 0.7s linear infinite; } -/* -- 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; +@keyframes spin { + to { transform: rotate(360deg); } } -.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 -------------------- */ +/* ── Radio Responsive ── */ @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: var(--space-3); - right: var(--space-3); - padding: var(--space-2) var(--space-3); - font-size: var(--text-base); + top: 12px; + right: 12px; + padding: 8px 10px; + font-size: 14px; } .radio-search { - top: var(--space-3); + top: 12px; width: calc(100% - 80px); left: calc(50% - 24px); } .radio-topbar { - padding: 0 var(--space-3); - gap: var(--space-2); + padding: 0 12px; + gap: 8px; } .radio-topbar-title { @@ -2626,45 +1542,11 @@ html, body { .radio-sel { max-width: 140px; - 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); + font-size: 12px; } } -/* -- 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; } @@ -2677,3 +1559,1356 @@ 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; + } +}