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 9000ddf..1fa7956 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -170,6 +170,8 @@ deploy: -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}}" + - echo "[Deploy] Cleaning up dangling images..." + - docker image prune -f || true bump-version: stage: bump-version 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 eeed0b4..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,8 +15,25 @@ 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> = { +const tabComponents: Record> = { radio: RadioTab, soundboard: SoundboardTab, lolstats: LolstatsTab, @@ -22,7 +42,7 @@ const tabComponents: Record> = { 'game-library': GameLibraryTab, }; -export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { +export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) { tabComponents[pluginName] = component; } @@ -40,6 +60,19 @@ export default function App() { const [showVersionModal, setShowVersionModal] = useState(false); const [pluginData, setPluginData] = useState>({}); + // ── Unified Auth State ── + const [user, setUser] = useState({ authenticated: false }); + const [providers, setProviders] = useState({ discord: false, steam: false, admin: false }); + const [showLoginModal, setShowLoginModal] = useState(false); + const [showUserSettings, setShowUserSettings] = useState(false); + const [showAdminPanel, setShowAdminPanel] = useState(false); + + // 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; const electronVersion = isElectron ? (window as any).electronAPI.version : null; @@ -54,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; @@ -153,6 +236,17 @@ export default function App() { 'game-library': '\u{1F3AE}', }; + // What happens when the user button is clicked + function handleUserButtonClick() { + if (!user.authenticated) { + setShowLoginModal(true); + } else if (isAdmin) { + setShowAdminPanel(true); + } else if (isRegularUser) { + setShowUserSettings(true); + } + } + return (
@@ -188,6 +282,34 @@ export default function App() { Desktop App )} + + {/* Unified Login / User button */} + +
)} + {/* 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 ? (
@@ -330,7 +481,7 @@ export default function App() { : { display: 'none' } } > - +
); }) 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 2f30c4d..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 }: { data: any }) { +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,13 +109,9 @@ export default function GameLibraryTab({ data }: { data: any }) { const filterInputRef = useRef(null); const [filterQuery, setFilterQuery] = useState(''); - // ── Admin state ── - const [showAdmin, setShowAdmin] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminProfiles, setAdminProfiles] = useState([]); - const [adminLoading, setAdminLoading] = useState(false); - const [adminError, setAdminError] = useState(''); + // ── Admin (centralized in App.tsx) ── + const _isAdmin = isAdminProp ?? false; + void _isAdmin; // ── SSE data sync ── useEffect(() => { @@ -133,76 +129,6 @@ export default function GameLibraryTab({ data }: { data: any }) { } catch { /* silent */ } }, []); - // ── Admin: check login status on mount ── - useEffect(() => { - fetch('/api/game-library/admin/status', { credentials: 'include' }) - .then(r => r.json()) - .then(d => setIsAdmin(d.admin === true)) - .catch(() => {}); - }, []); - - // ── Admin: login ── - const adminLogin = useCallback(async () => { - setAdminError(''); - try { - const resp = await fetch('/api/game-library/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), - credentials: 'include', - }); - if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - } else { - const d = await resp.json(); - setAdminError(d.error || 'Fehler'); - } - } catch { - setAdminError('Verbindung fehlgeschlagen'); - } - }, [adminPwd]); - - // ── Admin: logout ── - const adminLogout = useCallback(async () => { - await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); - setShowAdmin(false); - }, []); - - // ── Admin: load profiles ── - const loadAdminProfiles = useCallback(async () => { - 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(() => { @@ -552,9 +478,6 @@ export default function GameLibraryTab({ data }: { data: any }) { )}
-
{/* ── Profile Chips ── */} @@ -981,74 +904,6 @@ export default function GameLibraryTab({ data }: { data: any }) { ); })()} - {/* ── Admin Panel ── */} - {showAdmin && ( -
setShowAdmin(false)}> -
e.stopPropagation()}> -
-

⚙️ Game Library Admin

- -
- - {!isAdmin ? ( -
-

Admin-Passwort eingeben:

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

{adminError}

} -
- ) : ( -
-
- ✅ 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 372c8f0..d4a9c7f 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -62,7 +62,7 @@ align-items: center; gap: 8px; padding: 6px 12px; - border-radius: 20px; + border-radius: 4px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); cursor: pointer; @@ -234,7 +234,7 @@ gap: 6px; background: var(--bg-tertiary); padding: 6px 12px 6px 6px; - border-radius: 20px; + border-radius: 4px; cursor: pointer; transition: all var(--transition); } @@ -698,7 +698,7 @@ } .gl-sort-select option { - background: #1a1a2e; + background: #1a1810; color: #c7d5e0; } @@ -717,7 +717,7 @@ color: #8899a6; border: 1px solid rgba(255, 255, 255, 0.08); padding: 5px 12px; - border-radius: 20px; + border-radius: 4px; font-size: 12px; cursor: pointer; transition: all 0.2s; @@ -776,8 +776,8 @@ } .gl-dialog { - background: #2a2a3e; - border-radius: 12px; + background: #2a2620; + border-radius: 6px; padding: 24px; max-width: 500px; width: 90%; @@ -800,7 +800,7 @@ .gl-dialog-input { width: 100%; padding: 10px 12px; - background: #1a1a2e; + background: #1a1810; border: 1px solid #444; border-radius: 8px; color: #fff; @@ -841,7 +841,7 @@ .gl-dialog-cancel { padding: 8px 18px; - background: #3a3a4e; + background: #322d26; color: #ccc; border: none; border-radius: 8px; @@ -850,7 +850,7 @@ } .gl-dialog-cancel:hover { - background: #4a4a5e; + background: #3a352d; } .gl-dialog-submit { @@ -896,8 +896,8 @@ } .gl-admin-panel { - background: #2a2a3e; - border-radius: 12px; + background: #2a2620; + border-radius: 6px; padding: 0; max-width: 600px; width: 92%; diff --git a/web/src/plugins/lolstats/lolstats.css b/web/src/plugins/lolstats/lolstats.css index 59503c4..2bda0a2 100644 --- a/web/src/plugins/lolstats/lolstats.css +++ b/web/src/plugins/lolstats/lolstats.css @@ -70,7 +70,7 @@ gap: 6px; padding: 4px 10px; border: 1px solid var(--bg-tertiary); - border-radius: 16px; + border-radius: 4px; background: var(--bg-secondary); color: var(--text-muted); font-size: 12px; @@ -94,13 +94,13 @@ align-items: center; gap: 16px; padding: 16px; - border-radius: 12px; + border-radius: 6px; background: var(--bg-secondary); margin-bottom: 12px; } .lol-profile-icon { width: 72px; height: 72px; - border-radius: 12px; + border-radius: 6px; border: 2px solid var(--bg-tertiary); object-fit: cover; } @@ -170,7 +170,7 @@ .lol-ranked-card { flex: 1; padding: 12px 14px; - border-radius: 10px; + border-radius: 6px; background: var(--bg-secondary); border-left: 4px solid var(--bg-tertiary); } @@ -517,7 +517,7 @@ .lol-tier-mode-btn { padding: 6px 14px; border: 1px solid var(--bg-tertiary); - border-radius: 16px; + border-radius: 4px; background: var(--bg-secondary); color: var(--text-muted); font-size: 12px; diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index 064951b..a58c165 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -186,25 +186,6 @@ async function apiGetVolume(guildId: string): Promise { return typeof data?.volume === 'number' ? data.volume : 1; } -async function apiAdminStatus(): Promise { - const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' }); - if (!res.ok) return false; - const data = await res.json(); - return !!data?.authenticated; -} - -async function apiAdminLogin(password: string): Promise { - const res = await fetch(`${API_BASE}/admin/login`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ password }) - }); - return res.ok; -} - -async function apiAdminLogout(): Promise { - await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' }); -} - async function apiAdminDelete(paths: string[]): Promise { const res = await fetch(`${API_BASE}/admin/sounds/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -324,13 +305,14 @@ interface VoiceStats { interface SoundboardTabProps { data: any; + isAdmin?: boolean; } /* ══════════════════════════════════════════════════════════════════ COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function SoundboardTab({ data }: SoundboardTabProps) { +export default function SoundboardTab({ data, isAdmin: isAdminProp }: SoundboardTabProps) { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); @@ -378,15 +360,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { const volDebounceRef = useRef>(undefined); /* ── Admin ── */ - const [isAdmin, setIsAdmin] = useState(false); - const [showAdmin, setShowAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - 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); @@ -521,7 +495,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); } } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } - try { setIsAdmin(await apiAdminStatus()); } catch { } try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -656,13 +629,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { 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 { @@ -821,86 +787,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { 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'); - } - } - - async function handleAdminLogin() { - try { - const ok = await apiAdminLogin(adminPwd); - if (ok) { - setIsAdmin(true); - setAdminPwd(''); - notify('Admin eingeloggt'); - } - else notify('Falsches Passwort', 'error'); - } catch { notify('Login fehlgeschlagen', 'error'); } - } - - async function handleAdminLogout() { - try { - await apiAdminLogout(); - setIsAdmin(false); - setAdminSelection({}); - cancelRename(); - notify('Ausgeloggt'); - } catch { } - } - /* ── Computed ── */ const displaySounds = useMemo(() => { if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); @@ -938,26 +824,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { 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; @@ -1040,13 +906,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { )}
)} - @@ -1150,7 +1009,7 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { if (volDebounceRef.current) clearTimeout(volDebounceRef.current); volDebounceRef.current = setTimeout(() => { apiSetVolumeLive(guildId, v).catch(() => {}); - }, 120); + }, 50); } }} style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} @@ -1356,7 +1215,14 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
{ 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 @@ -1437,159 +1303,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
)} - {/* ═══ ADMIN PANEL ═══ */} - {showAdmin && ( -
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> -
-

- Admin - -

- {!isAdmin ? ( -
-
- - setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} - placeholder="Admin-Passwort..." - /> -
- -
- ) : ( -
-
-

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 && (
diff --git a/web/src/plugins/soundboard/soundboard.css b/web/src/plugins/soundboard/soundboard.css index be47e1a..4abee5f 100644 --- a/web/src/plugins/soundboard/soundboard.css +++ b/web/src/plugins/soundboard/soundboard.css @@ -6,10 +6,10 @@ Theme Variables — Default (Discord Blurple) ──────────────────────────────────────────── */ .sb-app { - --bg-deep: #1a1b1e; - --bg-primary: #1e1f22; - --bg-secondary: #2b2d31; - --bg-tertiary: #313338; + --bg-deep: #1a1810; + --bg-primary: #211e17; + --bg-secondary: #2a2620; + --bg-tertiary: #322d26; --bg-modifier-hover: rgba(79, 84, 92, .16); --bg-modifier-active: rgba(79, 84, 92, .24); --bg-modifier-selected: rgba(79, 84, 92, .32); @@ -183,7 +183,7 @@ gap: 8px; padding: 5px 12px 5px 10px; border: 1px solid rgba(255, 255, 255, .08); - border-radius: 20px; + border-radius: 4px; background: var(--bg-tertiary); color: var(--text-normal); font-family: var(--font); @@ -283,7 +283,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - border-radius: 20px; + border-radius: 4px; background: rgba(35, 165, 90, .12); font-size: 12px; color: var(--green); @@ -295,13 +295,12 @@ height: 7px; border-radius: 50%; background: var(--green); - box-shadow: 0 0 6px rgba(35, 165, 90, .6); animation: pulse-dot 2s ease-in-out infinite; } @keyframes pulse-dot { - 0%, 100% { box-shadow: 0 0 6px rgba(35, 165, 90, .5); } - 50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); } + 0%, 100% { opacity: .7; } + 50% { opacity: 1; } } .conn-ping { @@ -325,7 +324,7 @@ .conn-modal { background: var(--bg-primary); border: 1px solid var(--border); - border-radius: 16px; + border-radius: 4px; width: 340px; box-shadow: 0 20px 60px rgba(0,0,0,.4); overflow: hidden; @@ -437,7 +436,7 @@ align-items: center; gap: 6px; padding: 6px 14px; - border-radius: 20px; + border-radius: 4px; background: var(--bg-tertiary); color: var(--text-muted); font-family: var(--font); @@ -490,7 +489,7 @@ height: 32px; padding: 0 28px 0 32px; border: 1px solid rgba(255, 255, 255, .06); - border-radius: 20px; + border-radius: 4px; background: var(--bg-secondary); color: var(--text-normal); font-family: var(--font); @@ -541,7 +540,7 @@ max-width: 460px; flex: 1; padding: 4px 6px 4px 8px; - border-radius: 20px; + border-radius: 4px; background: var(--bg-secondary); border: 1px solid rgba(255, 255, 255, .08); } @@ -571,7 +570,7 @@ .url-import-btn { height: 24px; padding: 0 10px; - border-radius: 14px; + border-radius: 6px; border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .45); background: rgba(var(--accent-rgb, 88, 101, 242), .12); color: var(--accent); @@ -617,7 +616,7 @@ gap: 6px; padding: 6px 12px; border: 1px solid rgba(255, 255, 255, .08); - border-radius: 20px; + border-radius: 4px; background: var(--bg-tertiary); color: var(--text-muted); font-family: var(--font); @@ -656,20 +655,20 @@ .tb-btn.party:hover { background: var(--yellow); - color: #1a1b1e; + color: #1a1810; border-color: var(--yellow); } .tb-btn.party.active { background: var(--yellow); - color: #1a1b1e; + color: #1a1810; border-color: var(--yellow); animation: party-btn 600ms ease-in-out infinite alternate; } @keyframes party-btn { - from { box-shadow: 0 0 8px rgba(240, 178, 50, .4); } - to { box-shadow: 0 0 20px rgba(240, 178, 50, .7); } + from { opacity: .85; } + to { opacity: 1; } } .tb-btn.stop { @@ -689,7 +688,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - border-radius: 20px; + border-radius: 4px; background: var(--bg-tertiary); border: 1px solid rgba(255, 255, 255, .06); } @@ -739,7 +738,7 @@ align-items: center; gap: 4px; padding: 4px 8px; - border-radius: 20px; + border-radius: 4px; background: var(--bg-tertiary); border: 1px solid rgba(255, 255, 255, .06); } @@ -759,7 +758,6 @@ .theme-dot.active { border-color: var(--white); - box-shadow: 0 0 6px rgba(255, 255, 255, .3); } /* ── Analytics Strip ── */ @@ -778,7 +776,7 @@ align-items: center; gap: 8px; padding: 8px 12px; - border-radius: 12px; + border-radius: 6px; background: var(--bg-secondary); border: 1px solid rgba(255, 255, 255, .08); } @@ -871,7 +869,7 @@ align-items: center; gap: 6px; padding: 4px 12px; - border-radius: 20px; + border-radius: 4px; font-size: 12px; font-weight: 600; color: var(--text-muted); @@ -946,28 +944,13 @@ animation: card-enter 350ms ease-out forwards; } -.sound-card::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - opacity: 0; - transition: opacity var(--transition); - background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%); - pointer-events: none; -} - .sound-card:hover { background: var(--bg-tertiary); transform: translateY(-3px); - box-shadow: var(--shadow-med), 0 0 20px var(--accent-glow); + box-shadow: var(--shadow-med); border-color: rgba(88, 101, 242, .2); } -.sound-card:hover::before { - opacity: 1; -} - .sound-card:active { transform: translateY(0); transition-duration: 50ms; @@ -975,12 +958,7 @@ .sound-card.playing { border-color: var(--accent); - animation: card-enter 350ms ease-out forwards, playing-glow 1.2s ease-in-out infinite alternate; -} - -@keyframes playing-glow { - from { box-shadow: 0 0 4px var(--accent-glow); } - to { box-shadow: 0 0 16px var(--accent-glow); } + animation: card-enter 350ms ease-out forwards; } @keyframes card-enter { @@ -1170,7 +1148,7 @@ align-items: center; gap: 6px; padding: 4px 12px; - border-radius: 20px; + border-radius: 4px; background: rgba(var(--accent-rgb, 88, 101, 242), .12); border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2); font-size: 12px; @@ -1221,7 +1199,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - border-radius: 20px; + border-radius: 4px; background: var(--bg-tertiary); border: 1px solid rgba(255, 255, 255, .06); } @@ -1301,20 +1279,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(45deg, - rgba(255, 0, 0, .04), - rgba(0, 255, 0, .04), - rgba(0, 0, 255, .04), - rgba(255, 255, 0, .04) - ); - background-size: 400% 400%; - animation: party-grad 3s ease infinite; -} - -@keyframes party-grad { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } + background: rgba(255, 255, 255, .03); } @keyframes party-hue { @@ -1386,7 +1351,7 @@ left: 50%; transform: translateX(-50%); padding: 10px 20px; - border-radius: 20px; + border-radius: 4px; font-size: 13px; font-weight: 600; z-index: 100; @@ -1602,7 +1567,7 @@ justify-content: space-between; gap: 10px; padding: 8px 10px; - border-radius: 10px; + border-radius: 6px; background: var(--bg-tertiary); border: 1px solid rgba(255, 255, 255, .08); flex-wrap: wrap; @@ -1626,7 +1591,7 @@ max-height: 52vh; overflow-y: auto; border: 1px solid rgba(255, 255, 255, .08); - border-radius: 10px; + border-radius: 6px; background: var(--bg-primary); } @@ -1852,7 +1817,7 @@ align-items: center; gap: 14px; padding: 64px 72px; - border-radius: 24px; + border-radius: 6px; border: 2.5px dashed rgba(var(--accent-rgb), .55); background: rgba(var(--accent-rgb), .07); animation: drop-pulse 2.2s ease-in-out infinite; @@ -1861,11 +1826,9 @@ @keyframes drop-pulse { 0%, 100% { border-color: rgba(var(--accent-rgb), .45); - box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } 50% { border-color: rgba(var(--accent-rgb), .9); - box-shadow: 0 0 60px 12px rgba(var(--accent-rgb), .12); } } @@ -1902,7 +1865,7 @@ width: 340px; background: var(--bg-secondary); border: 1px solid rgba(255, 255, 255, .09); - border-radius: 14px; + border-radius: 6px; box-shadow: 0 8px 40px rgba(0, 0, 0, .45); z-index: 200; animation: slide-up 200ms cubic-bezier(.16,1,.3,1); @@ -2048,7 +2011,7 @@ width: 420px; max-width: 92vw; background: var(--bg-secondary); border: 1px solid rgba(255, 255, 255, .1); - border-radius: 16px; + border-radius: 4px; box-shadow: 0 12px 60px rgba(0, 0, 0, .5); animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1); } diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index cd85e68..a511798 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -46,23 +46,22 @@ function formatElapsed(startedAt: string): string { // ── Quality Presets ── const QUALITY_PRESETS = [ - { label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 }, - { label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 }, - { label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 }, - { label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 }, - { label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 }, - { label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 }, + { label: 'Niedrig \u00B7 4 Mbit \u00B7 60fps', fps: 60, bitrate: 4_000_000 }, + { label: 'Mittel \u00B7 8 Mbit \u00B7 60fps', fps: 60, bitrate: 8_000_000 }, + { label: 'Hoch \u00B7 14 Mbit \u00B7 60fps', fps: 60, bitrate: 14_000_000 }, + { label: 'Ultra \u00B7 25 Mbit \u00B7 60fps', fps: 60, bitrate: 25_000_000 }, + { label: 'Max \u00B7 50 Mbit \u00B7 165fps', fps: 165, bitrate: 50_000_000 }, ] as const; // ── Component ── -export default function StreamingTab({ data }: { data: any }) { +export default function StreamingTab({ data, isAdmin: isAdminProp }: { data: any; isAdmin?: boolean }) { // ── State ── const [streams, setStreams] = useState([]); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); const [streamTitle, setStreamTitle] = useState('Screen Share'); const [streamPassword, setStreamPassword] = useState(''); - const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60 + const [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60 const [error, setError] = useState(null); const [joinModal, setJoinModal] = useState(null); const [myStreamId, setMyStreamId] = useState(null); @@ -73,16 +72,9 @@ export default function StreamingTab({ data }: { data: any }) { const [openMenu, setOpenMenu] = useState(null); const [copiedId, setCopiedId] = useState(null); - // ── Admin / Notification Config ── - const [showAdmin, setShowAdmin] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminError, setAdminError] = useState(''); - const [availableChannels, setAvailableChannels] = useState>([]); - const [notifyConfig, setNotifyConfig] = useState>([]); - const [configLoading, setConfigLoading] = useState(false); - const [configSaving, setConfigSaving] = useState(false); - const [notifyStatus, setNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null }); + // ── Admin ── + const _isAdmin = isAdminProp ?? false; + void _isAdmin; // kept for potential future use // ── Refs ── const wsRef = useRef(null); @@ -100,7 +92,7 @@ export default function StreamingTab({ data }: { data: any }) { // Refs that mirror state (avoid stale closures in WS handler) const isBroadcastingRef = useRef(false); const viewingRef = useRef(null); - const qualityRef = useRef(QUALITY_PRESETS[2]); + const qualityRef = useRef(QUALITY_PRESETS[1]); useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]); useEffect(() => { viewingRef.current = viewing; }, [viewing]); useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]); @@ -138,17 +130,6 @@ export default function StreamingTab({ data }: { data: any }) { return () => document.removeEventListener('click', handler); }, [openMenu]); - // Check admin status on mount - useEffect(() => { - fetch('/api/notifications/admin/status', { credentials: 'include' }) - .then(r => r.json()) - .then(d => setIsAdmin(d.admin === true)) - .catch(() => {}); - fetch('/api/notifications/status') - .then(r => r.json()) - .then(d => setNotifyStatus(d)) - .catch(() => {}); - }, []); // ── Send via WS ── const wsSend = useCallback((d: Record) => { @@ -422,7 +403,7 @@ export default function StreamingTab({ data }: { data: any }) { try { const q = qualityRef.current; const stream = await navigator.mediaDevices.getDisplayMedia({ - video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } }, + video: { frameRate: { ideal: q.fps } }, audio: true, }); localStreamRef.current = stream; @@ -610,97 +591,6 @@ export default function StreamingTab({ data }: { data: any }) { setOpenMenu(null); }, [buildStreamLink]); - // ── Admin functions ── - const adminLogin = useCallback(async () => { - setAdminError(''); - try { - const resp = await fetch('/api/notifications/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), - credentials: 'include', - }); - if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - loadNotifyConfig(); - } else { - const d = await resp.json(); - setAdminError(d.error || 'Fehler'); - } - } catch { - setAdminError('Verbindung fehlgeschlagen'); - } - }, [adminPwd]); - - const adminLogout = useCallback(async () => { - await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); - setShowAdmin(false); - }, []); - - const loadNotifyConfig = useCallback(async () => { - setConfigLoading(true); - try { - const [chResp, cfgResp] = await Promise.all([ - fetch('/api/notifications/channels', { credentials: 'include' }), - fetch('/api/notifications/config', { credentials: 'include' }), - ]); - if (chResp.ok) { - const chData = await chResp.json(); - setAvailableChannels(chData.channels || []); - } - if (cfgResp.ok) { - const cfgData = await cfgResp.json(); - setNotifyConfig(cfgData.channels || []); - } - } catch { /* silent */ } - finally { setConfigLoading(false); } - }, []); - - const openAdmin = useCallback(() => { - setShowAdmin(true); - if (isAdmin) loadNotifyConfig(); - }, [isAdmin, loadNotifyConfig]); - - const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => { - setNotifyConfig(prev => { - const existing = prev.find(c => c.channelId === channelId); - if (existing) { - const hasEvent = existing.events.includes(event); - const newEvents = hasEvent - ? existing.events.filter(e => e !== event) - : [...existing.events, event]; - if (newEvents.length === 0) { - return prev.filter(c => c.channelId !== channelId); - } - return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c); - } else { - return [...prev, { channelId, channelName, guildId, guildName, events: [event] }]; - } - }); - }, []); - - const saveNotifyConfig = useCallback(async () => { - setConfigSaving(true); - try { - const resp = await fetch('/api/notifications/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ channels: notifyConfig }), - credentials: 'include', - }); - if (resp.ok) { - // brief visual feedback handled by configSaving state - } - } catch { /* silent */ } - finally { setConfigSaving(false); } - }, [notifyConfig]); - - const isChannelEventEnabled = useCallback((channelId: string, event: string): boolean => { - const ch = notifyConfig.find(c => c.channelId === channelId); - return ch?.events.includes(event) ?? false; - }, [notifyConfig]); // ── Render ── @@ -754,39 +644,50 @@ export default function StreamingTab({ data }: { data: any }) { )}
- setUserName(e.target.value)} - disabled={isBroadcasting} - /> - setStreamTitle(e.target.value)} - disabled={isBroadcasting} - /> - setStreamPassword(e.target.value)} - disabled={isBroadcasting} - /> - + + + + {isBroadcasting ? ( )} -
{streams.length === 0 && !isBroadcasting ? ( @@ -903,100 +801,6 @@ export default function StreamingTab({ data }: { data: any }) {
)} - {/* ── Notification Admin Modal ── */} - {showAdmin && ( -
setShowAdmin(false)}> -
e.stopPropagation()}> -
-

{'\uD83D\uDD14'} Benachrichtigungen

- -
- - {!isAdmin ? ( -
-

Admin-Passwort eingeben:

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

{adminError}

} -
- ) : ( -
-
- - {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 9d43c2e..abdf17a 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -9,12 +9,27 @@ /* ── Top Bar ── */ .stream-topbar { display: flex; - align-items: center; + align-items: flex-end; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.stream-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.stream-field-grow { flex: 1; min-width: 180px; } +.stream-field-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + padding-left: 2px; +} + .stream-input { padding: 10px 14px; border: 1px solid var(--bg-tertiary); @@ -25,11 +40,13 @@ outline: none; transition: border-color var(--transition); min-width: 0; + width: 100%; + box-sizing: border-box; } .stream-input:focus { border-color: var(--accent); } .stream-input::placeholder { color: var(--text-faint); } .stream-input-name { width: 150px; } -.stream-input-title { flex: 1; min-width: 180px; } +.stream-input-title { width: 100%; } .stream-btn { padding: 10px 20px; @@ -405,11 +422,12 @@ /* ── Password input in topbar ── */ .stream-input-password { - width: 140px; + width: 180px; } .stream-select-quality { - width: 120px; + width: 210px; + box-sizing: border-box; padding: 10px 14px; border: 1px solid var(--bg-tertiary); border-radius: var(--radius); @@ -518,7 +536,7 @@ .stream-admin-panel { background: var(--bg-secondary); - border-radius: 12px; + border-radius: 6px; width: 560px; max-width: 95vw; max-height: 80vh; @@ -631,7 +649,7 @@ align-items: center; gap: 4px; padding: 4px 10px; - border-radius: 14px; + border-radius: 6px; font-size: 12px; color: var(--text-muted); background: var(--bg-secondary); diff --git a/web/src/plugins/watch-together/watch-together.css b/web/src/plugins/watch-together/watch-together.css index 1873674..32684b4 100644 --- a/web/src/plugins/watch-together/watch-together.css +++ b/web/src/plugins/watch-together/watch-together.css @@ -554,9 +554,9 @@ } .wt-quality-select { - background: var(--bg-secondary, #2a2a3e); + background: var(--bg-secondary, #2a2620); color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #3a3a4e); + border: 1px solid var(--border-color, #322d26); border-radius: 6px; padding: 2px 6px; font-size: 12px; @@ -862,9 +862,9 @@ border-radius: 50%; flex-shrink: 0; } -.wt-sync-synced { background: #2ecc71; box-shadow: 0 0 6px rgba(46, 204, 113, 0.5); } -.wt-sync-drifting { background: #f1c40f; box-shadow: 0 0 6px rgba(241, 196, 15, 0.5); } -.wt-sync-desynced { background: #e74c3c; box-shadow: 0 0 6px rgba(231, 76, 60, 0.5); } +.wt-sync-synced { background: #2ecc71; } +.wt-sync-drifting { background: #f1c40f; } +.wt-sync-desynced { background: #e74c3c; } /* ══════════════════════════════════════ VOTE BUTTONS diff --git a/web/src/styles.css b/web/src/styles.css index 656b9d5..4d5c046 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,24 +1,35 @@ +/* ── Google Fonts ── */ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=DM+Mono:wght@400;500&display=swap'); + /* ── CSS Variables ── */ :root { - --bg-deep: #1a1b1e; - --bg-primary: #1e1f22; - --bg-secondary: #2b2d31; - --bg-tertiary: #313338; + --bg-deep: #1a1810; + --bg-primary: #211e17; + --bg-secondary: #2a2620; + --bg-tertiary: #322d26; + --bg-card: #2a2620; + --bg-card-hover: #322d26; + --bg-input: #1e1b15; + --bg-header: #1e1b14; --text-normal: #dbdee1; --text-muted: #949ba4; --text-faint: #6d6f78; --accent: #e67e22; --accent-rgb: 230, 126, 34; --accent-hover: #d35400; + --accent-dim: rgba(230, 126, 34, 0.15); + --accent-border: rgba(230, 126, 34, 0.35); --success: #57d28f; --danger: #ed4245; --warning: #fee75c; - --border: rgba(255, 255, 255, 0.06); - --radius: 8px; - --radius-lg: 12px; + --border: rgba(255, 255, 255, 0.05); + --border-strong: rgba(255, 255, 255, 0.08); + --radius: 4px; + --radius-lg: 6px; --transition: 150ms ease; - --font: 'Segoe UI', system-ui, -apple-system, sans-serif; - --header-height: 56px; + --font: 'DM Sans', system-ui, -apple-system, sans-serif; + --mono: 'DM Mono', monospace; + --header-height: 44px; } /* ── Reset & Base ── */ @@ -33,7 +44,7 @@ html, body { height: 100%; font-family: var(--font); - font-size: 15px; + font-size: 13px; color: var(--text-normal); background: var(--bg-deep); -webkit-font-smoothing: antialiased; @@ -261,7 +272,7 @@ html, body { .hub-update-modal { background: var(--bg-card); border: 1px solid var(--border); - border-radius: 12px; + border-radius: 6px; padding: 32px 40px; text-align: center; min-width: 320px; @@ -353,6 +364,104 @@ html, body { background: rgba(230, 126, 34, 0.1); } +/* ── Admin Button (header) ── */ +.hub-admin-btn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 16px; + padding: 4px 8px; + cursor: pointer; + transition: all var(--transition); + line-height: 1; +} +.hub-admin-btn:hover { + color: var(--accent); + border-color: var(--accent); +} +.hub-admin-btn.active { + color: #4ade80; + border-color: #4ade80; +} + +/* ── 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; @@ -377,7 +486,7 @@ html, body { .hub-version-modal { background: var(--bg-primary); border: 1px solid var(--border); - border-radius: 16px; + border-radius: 4px; width: 340px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); overflow: hidden; @@ -700,10 +809,10 @@ html, body { background: var(--bg-deep); /* Default-Theme Vars (scoped, damit data-theme sie überschreiben kann) */ - --bg-deep: #1a1b1e; - --bg-primary: #1e1f22; - --bg-secondary: #2b2d31; - --bg-tertiary: #313338; + --bg-deep: #1a1810; + --bg-primary: #211e17; + --bg-secondary: #2a2620; + --bg-tertiary: #322d26; --text-normal: #dbdee1; --text-muted: #949ba4; --text-faint: #6d6f78; @@ -761,7 +870,7 @@ html, body { align-items: center; padding: 0 16px; height: 52px; - background: var(--bg-secondary, #2b2d31); + background: var(--bg-secondary, #2a2620); border-bottom: 1px solid rgba(0, 0, 0, .24); z-index: 10; flex-shrink: 0; @@ -860,8 +969,7 @@ html, body { .radio-search-wrap { display: flex; align-items: center; - background: rgba(30, 31, 34, 0.92); - backdrop-filter: blur(12px); + background: rgba(33, 30, 23, 0.92); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0 14px; @@ -908,8 +1016,7 @@ html, body { /* ── Search Results ── */ .radio-search-results { margin-top: 6px; - background: rgba(30, 31, 34, 0.95); - backdrop-filter: blur(12px); + background: rgba(33, 30, 23, 0.95); border: 1px solid var(--border); border-radius: var(--radius-lg); max-height: 360px; @@ -981,8 +1088,7 @@ html, body { align-items: center; gap: 4px; padding: 10px 14px; - background: rgba(30, 31, 34, 0.92); - backdrop-filter: blur(12px); + background: rgba(33, 30, 23, 0.92); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-normal); @@ -1004,7 +1110,7 @@ html, body { background: var(--accent); color: #fff; padding: 1px 6px; - border-radius: 10px; + border-radius: 6px; min-width: 18px; text-align: center; } @@ -1017,8 +1123,7 @@ html, body { width: 340px; height: 100%; z-index: 15; - background: rgba(30, 31, 34, 0.95); - backdrop-filter: blur(16px); + background: rgba(33, 30, 23, 0.95); border-left: 1px solid var(--border); display: flex; flex-direction: column; @@ -1309,7 +1414,7 @@ html, body { width: 100px; height: 4px; border-radius: 2px; - background: var(--bg-tertiary, #383a40); + background: var(--bg-tertiary, #3a352d); outline: none; cursor: pointer; } @@ -1358,7 +1463,6 @@ html, body { .radio-theme-dot.active { border-color: #fff; - box-shadow: 0 0 6px rgba(255, 255, 255, 0.3); } /* ── Station count ── */ @@ -1369,7 +1473,7 @@ html, body { z-index: 10; font-size: 12px; color: var(--text-faint); - background: rgba(30, 31, 34, 0.8); + background: rgba(33, 30, 23, 0.8); padding: 4px 10px; border-radius: 20px; pointer-events: none; @@ -1382,7 +1486,7 @@ html, body { z-index: 10; font-size: 12px; color: var(--text-faint); - background: rgba(30, 31, 34, 0.8); + background: rgba(33, 30, 23, 0.8); padding: 4px 10px; border-radius: 20px; text-decoration: none; @@ -1391,7 +1495,7 @@ html, body { .radio-attribution:hover { color: var(--text-normal); - background: rgba(30, 31, 34, 0.92); + background: rgba(33, 30, 23, 0.92); } /* ── Spinner ── */ @@ -1507,7 +1611,7 @@ html, body { .radio-modal { background: var(--bg-primary); border: 1px solid var(--border); - border-radius: 16px; + border-radius: 4px; width: 340px; box-shadow: 0 20px 60px rgba(0, 0, 0, .4); overflow: hidden; @@ -1578,3 +1682,1233 @@ html, body { 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; + } +}