From 71b35d573e91acea7817bfe5dd6c85063ac55aaf Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 8 Mar 2026 16:53:01 +0100 Subject: [PATCH] Game Library: Admin-Panel, Disconnect-UI, IGDB-Cache - Admin-Panel mit Login (gleiches Passwort wie Soundboard) zum Entfernen von Profilen inkl. aller verknuepften Daten - Disconnect-Buttons im Profil-Detail: Steam/GOG einzeln trennbar - IGDB persistenter File-Cache (ueberlebt Server-Neustarts) - SSE-Broadcast-Format korrigiert (buildProfileSummaries shared helper) - DELETE-Endpoints fuer Profil/Steam/GOG Trennung Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/game-library/igdb.ts | 134 ++++++++- server/src/plugins/game-library/index.ts | 265 +++++++++++++++--- .../plugins/game-library/GameLibraryTab.tsx | 203 +++++++++++++- web/src/plugins/game-library/game-library.css | 233 +++++++++++++++ 4 files changed, 786 insertions(+), 49 deletions(-) diff --git a/server/src/plugins/game-library/igdb.ts b/server/src/plugins/game-library/igdb.ts index 67e3bef..46fe1a6 100644 --- a/server/src/plugins/game-library/igdb.ts +++ b/server/src/plugins/game-library/igdb.ts @@ -2,8 +2,12 @@ // IGDB (Internet Game Database) API service // Uses Twitch OAuth for authentication. All requests go through a simple // throttle so we never exceed the 4 req/s rate-limit. +// Includes a persistent file-based cache so results survive server restarts. // ────────────────────────────────────────────────────────────────────────────── +import fs from 'node:fs'; +import path from 'node:path'; + const TWITCH_CLIENT_ID = 'n6u8unhmwvhzsrvw2d2nb2a3qxapsl'; const TWITCH_CLIENT_SECRET = 'h6f6g2r6yyxkfg2xsob0jt8p994s7v'; const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/token'; @@ -247,17 +251,84 @@ export async function lookupGame( return lookupByName(gameName); } -// ── Batch enrichment ───────────────────────────────────────────────────────── +// ── Persistent file-based cache ────────────────────────────────────────────── -/** In-memory cache keyed by Steam appid. Persists for the server lifetime. */ +/** In-memory cache keyed by Steam appid. Loaded from disk on startup. */ const enrichmentCache = new Map(); +/** Cache for name-based lookups (GOG games etc.), keyed by lowercase game name */ +const nameCache = new Map(); + +let cacheFilePath: string | null = null; +let cacheDirty = false; +let cacheSaveTimer: ReturnType | null = null; + +/** + * Initialize the persistent IGDB cache. + * Call once during plugin init with the data directory path. + */ +export function initIgdbCache(dataDir: string): void { + cacheFilePath = path.join(dataDir, 'igdb-cache.json'); + try { + if (fs.existsSync(cacheFilePath)) { + const raw = fs.readFileSync(cacheFilePath, 'utf-8'); + const parsed = JSON.parse(raw) as { appid: Record; name?: Record }; + + // Load appid-keyed cache + if (parsed.appid) { + for (const [key, value] of Object.entries(parsed.appid)) { + enrichmentCache.set(Number(key), value); + } + } + + // Load name-keyed cache + if (parsed.name) { + for (const [key, value] of Object.entries(parsed.name)) { + nameCache.set(key, value); + } + } + + console.log(`[IGDB] Cache loaded: ${enrichmentCache.size} appid entries, ${nameCache.size} name entries`); + } + } catch (err) { + console.error('[IGDB] Failed to load cache:', err); + } +} + +/** Save cache to disk (debounced — writes at most every 5 seconds). */ +function scheduleCacheSave(): void { + if (!cacheFilePath) return; + cacheDirty = true; + if (cacheSaveTimer) return; // already scheduled + cacheSaveTimer = setTimeout(() => { + cacheSaveTimer = null; + if (!cacheDirty || !cacheFilePath) return; + cacheDirty = false; + try { + const dir = path.dirname(cacheFilePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const appidObj: Record = {}; + for (const [k, v] of enrichmentCache) appidObj[String(k)] = v; + + const nameObj: Record = {}; + for (const [k, v] of nameCache) nameObj[k] = v; + + fs.writeFileSync(cacheFilePath, JSON.stringify({ appid: appidObj, name: nameObj }), 'utf-8'); + } catch (err) { + console.error('[IGDB] Failed to save cache:', err); + } + }, 5000); +} + +// ── Batch enrichment ───────────────────────────────────────────────────────── + /** * Enrich a list of Steam games with IGDB metadata. * * - Processes in batches of 4 (respecting the 4 req/s rate limit). * - 300 ms pause between batches. - * - Uses an in-memory cache so repeated calls for the same appid are free. + * - Uses a persistent file-based cache so results survive restarts. * * @returns Map keyed by Steam appid → IgdbGameInfo (only matched games) */ @@ -310,9 +381,66 @@ export async function enrichGames( await Promise.all(promises); } + // Persist cache to disk + scheduleCacheSave(); + console.log( `[IGDB] Enrichment complete: ${result.size}/${games.length} games matched`, ); return result; } + +/** + * Enrich a list of games by name (for GOG games that don't have Steam appids). + * + * @returns Map keyed by lowercase game name → IgdbGameInfo + */ +export async function enrichGamesByName( + names: string[], +): Promise> { + const result = new Map(); + const toFetch: string[] = []; + + for (const name of names) { + const key = name.toLowerCase(); + const cached = nameCache.get(key); + if (cached) { + result.set(key, cached); + } else { + toFetch.push(name); + } + } + + if (toFetch.length === 0) { + return result; + } + + console.log(`[IGDB] Name lookup: ${result.size} cache hits, ${toFetch.length} to fetch`); + + const BATCH_SIZE = 4; + for (let i = 0; i < toFetch.length; i += BATCH_SIZE) { + if (i > 0) { + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + const batch = toFetch.slice(i, i + BATCH_SIZE); + const promises = batch.map(async (name) => { + try { + const info = await lookupByName(name); + if (info) { + const key = name.toLowerCase(); + nameCache.set(key, info); + result.set(key, info); + } + } catch (err) { + console.error(`[IGDB] Error looking up "${name}":`, err); + } + }); + + await Promise.all(promises); + } + + scheduleCacheSave(); + return result; +} diff --git a/server/src/plugins/game-library/index.ts b/server/src/plugins/game-library/index.ts index 46ba1de..143ae97 100644 --- a/server/src/plugins/game-library/index.ts +++ b/server/src/plugins/game-library/index.ts @@ -4,7 +4,7 @@ import { sseBroadcast } from '../../core/sse.js'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; -import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js'; +import { lookupGame, enrichGames, enrichGamesByName, initIgdbCache, type IgdbGameInfo } from './igdb.js'; import { getGogAuthUrl, exchangeGogCode, refreshGogToken, fetchGogUserInfo, fetchGogGames, type GogGame } from './gog.js'; // ── Types ── @@ -56,6 +56,37 @@ interface GameLibraryData { const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166DF32'; +// ── Admin auth helpers (same system as soundboard) ── + +function readCookie(req: express.Request, name: string): string | undefined { + const raw = req.headers.cookie || ''; + const match = raw.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : undefined; +} + +function b64url(str: string): string { + return Buffer.from(str).toString('base64url'); +} + +function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { + if (!token || !adminPwd) return false; + const [body, sig] = token.split('.'); + if (!body || !sig) return false; + const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + if (expected !== sig) return false; + try { + const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as { iat: number; exp: number }; + return typeof payload.exp === 'number' && Date.now() < payload.exp; + } catch { return false; } +} + +function signAdminToken(adminPwd: string): string { + const payload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 }; + const body = b64url(JSON.stringify(payload)); + const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + return `${body}.${sig}`; +} + // ── Data Persistence ── function getDataPath(ctx: PluginContext): string { @@ -87,24 +118,27 @@ function saveData(ctx: PluginContext, data: GameLibraryData): void { // ── SSE Broadcast ── +function buildProfileSummaries(data: GameLibraryData) { + return Object.values(data.profiles || {}).map(p => { + const steam = p.steamId ? data.users[p.steamId] : null; + const gog = p.gogUserId ? data.gogUsers?.[p.gogUserId] : null; + return { + id: p.id, + displayName: p.displayName, + avatarUrl: p.avatarUrl, + platforms: { + steam: steam ? { steamId: steam.steamId, personaName: steam.personaName, gameCount: steam.games.length } : null, + gog: gog ? { gogUserId: gog.gogUserId, username: gog.username, gameCount: gog.games.length } : null, + }, + totalGames: (steam?.games.length || 0) + (gog?.games.length || 0), + lastUpdated: p.lastUpdated, + }; + }); +} + function broadcastUpdate(data: GameLibraryData): void { - const users = Object.values(data.users).map(u => ({ - steamId: u.steamId, - personaName: u.personaName, - avatarUrl: u.avatarUrl, - gameCount: u.games.length, - lastUpdated: u.lastUpdated, - })); - const profiles = Object.values(data.profiles || {}).map(p => ({ - id: p.id, - displayName: p.displayName, - avatarUrl: p.avatarUrl, - steamId: p.steamId, - gogUserId: p.gogUserId, - totalGames: (p.steamId && data.users[p.steamId] ? data.users[p.steamId].games.length : 0) + - (p.gogUserId && data.gogUsers?.[p.gogUserId] ? data.gogUsers[p.gogUserId].games.length : 0), - })); - sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users, profiles }); + const profiles = buildProfileSummaries(data); + sseBroadcast({ type: 'game_library_update', plugin: 'game-library', profiles }); } // ── Steam API Helpers ── @@ -151,6 +185,9 @@ const gameLibraryPlugin: Plugin = { description: 'Steam Spielebibliothek', async init(ctx) { + // Initialize persistent IGDB cache + initIgdbCache(ctx.dataDir); + const data = loadData(ctx); // ensure file exists // Migrate: ensure gogUsers and profiles exist @@ -595,22 +632,7 @@ const gameLibraryPlugin: Plugin = { // ── GET /api/game-library/profiles ── app.get('/api/game-library/profiles', (_req, res) => { const data = loadData(ctx); - const profiles = Object.values(data.profiles).map(p => { - const steam = p.steamId ? data.users[p.steamId] : null; - const gog = p.gogUserId ? data.gogUsers[p.gogUserId] : null; - return { - id: p.id, - displayName: p.displayName, - avatarUrl: p.avatarUrl, - platforms: { - steam: steam ? { steamId: steam.steamId, personaName: steam.personaName, gameCount: steam.games.length } : null, - gog: gog ? { gogUserId: gog.gogUserId, username: gog.username, gameCount: gog.games.length } : null, - }, - totalGames: (steam?.games.length || 0) + (gog?.games.length || 0), - lastUpdated: p.lastUpdated, - }; - }); - res.json({ profiles }); + res.json({ profiles: buildProfileSummaries(data) }); }); // ── GOG Login (redirect to GOG auth page) ── @@ -774,19 +796,180 @@ const gameLibraryPlugin: Plugin = { console.log(`[GameLibrary] Benutzer entfernt: ${personaName} (${steamId})`); res.json({ success: true, message: `${personaName} wurde entfernt.` }); }); + + // ── DELETE /api/game-library/profile/:profileId/steam ── Unlink Steam + app.delete('/api/game-library/profile/:profileId/steam', (req, res) => { + const data = loadData(ctx); + const profile = data.profiles[req.params.profileId]; + if (!profile) { res.status(404).json({ error: 'Profil nicht gefunden.' }); return; } + if (!profile.steamId) { res.status(400).json({ error: 'Kein Steam-Konto verknuepft.' }); return; } + + const steamId = profile.steamId; + const name = data.users[steamId]?.personaName || steamId; + delete profile.steamId; + profile.lastUpdated = new Date().toISOString(); + + // Wenn kein anderes Profil diesen Steam-User nutzt, Daten entfernen + const stillUsed = Object.values(data.profiles).some(p => p.steamId === steamId); + if (!stillUsed) delete data.users[steamId]; + + // Profil löschen wenn keine Plattform mehr verknüpft + if (!profile.steamId && !profile.gogUserId) { + delete data.profiles[req.params.profileId]; + console.log(`[GameLibrary] Profil geloescht (keine Plattformen): ${profile.displayName}`); + } + + saveData(ctx, data); + broadcastUpdate(data); + console.log(`[GameLibrary] Steam getrennt: ${name} von ${profile.displayName}`); + res.json({ ok: true }); + }); + + // ── DELETE /api/game-library/profile/:profileId/gog ── Unlink GOG + app.delete('/api/game-library/profile/:profileId/gog', (req, res) => { + const data = loadData(ctx); + const profile = data.profiles[req.params.profileId]; + if (!profile) { res.status(404).json({ error: 'Profil nicht gefunden.' }); return; } + if (!profile.gogUserId) { res.status(400).json({ error: 'Kein GOG-Konto verknuepft.' }); return; } + + const gogUserId = profile.gogUserId; + const name = data.gogUsers?.[gogUserId]?.username || gogUserId; + delete profile.gogUserId; + profile.lastUpdated = new Date().toISOString(); + + // Wenn kein anderes Profil diesen GOG-User nutzt, Daten entfernen + const stillUsed = Object.values(data.profiles).some(p => p.gogUserId === gogUserId); + if (!stillUsed && data.gogUsers) delete data.gogUsers[gogUserId]; + + // Profil löschen wenn keine Plattform mehr verknüpft + if (!profile.steamId && !profile.gogUserId) { + delete data.profiles[req.params.profileId]; + console.log(`[GameLibrary] Profil geloescht (keine Plattformen): ${profile.displayName}`); + } + + saveData(ctx, data); + broadcastUpdate(data); + console.log(`[GameLibrary] GOG getrennt: ${name} von ${profile.displayName}`); + res.json({ ok: true }); + }); + + // ── DELETE /api/game-library/profile/:profileId ── Ganzes Profil löschen + app.delete('/api/game-library/profile/:profileId', (req, res) => { + const data = loadData(ctx); + const profile = data.profiles[req.params.profileId]; + if (!profile) { res.status(404).json({ error: 'Profil nicht gefunden.' }); return; } + + const displayName = profile.displayName; + + // Zugehörige Daten aufräumen wenn nicht anderweitig genutzt + if (profile.steamId) { + const stillUsed = Object.values(data.profiles).some(p => p.id !== profile.id && p.steamId === profile.steamId); + if (!stillUsed) delete data.users[profile.steamId]; + } + if (profile.gogUserId) { + const stillUsed = Object.values(data.profiles).some(p => p.id !== profile.id && p.gogUserId === profile.gogUserId); + if (!stillUsed && data.gogUsers) delete data.gogUsers[profile.gogUserId]; + } + + delete data.profiles[req.params.profileId]; + saveData(ctx, data); + broadcastUpdate(data); + + console.log(`[GameLibrary] Profil geloescht: ${displayName}`); + res.json({ ok: true }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Admin endpoints (same auth as soundboard) + // ═══════════════════════════════════════════════════════════════════ + + const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { + if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { + res.status(401).json({ error: 'Nicht eingeloggt' }); + return; + } + next(); + }; + + // ── GET /api/game-library/admin/status ── + app.get('/api/game-library/admin/status', (req, res) => { + if (!ctx.adminPwd) { res.json({ admin: false, configured: false }); return; } + const valid = verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')); + res.json({ admin: valid, configured: true }); + }); + + // ── POST /api/game-library/admin/login ── + app.post('/api/game-library/admin/login', (req, res) => { + const password = String(req.body?.password || ''); + if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } + const token = signAdminToken(ctx.adminPwd); + res.setHeader('Set-Cookie', `admin=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); + res.json({ ok: true }); + }); + + // ── POST /api/game-library/admin/logout ── + app.post('/api/game-library/admin/logout', (_req, res) => { + res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); + res.json({ ok: true }); + }); + + // ── GET /api/game-library/admin/profiles ── Alle Profile mit Details + app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => { + const data = loadData(ctx); + const profiles = Object.values(data.profiles).map(p => { + const steam = p.steamId ? data.users[p.steamId] : null; + const gog = p.gogUserId ? data.gogUsers?.[p.gogUserId] : null; + return { + id: p.id, + displayName: p.displayName, + avatarUrl: p.avatarUrl, + steamId: p.steamId || null, + steamName: steam?.personaName || null, + steamGames: steam?.games.length || 0, + gogUserId: p.gogUserId || null, + gogName: gog?.username || null, + gogGames: gog?.games.length || 0, + totalGames: (steam?.games.length || 0) + (gog?.games.length || 0), + lastUpdated: p.lastUpdated, + }; + }); + res.json({ profiles }); + }); + + // ── DELETE /api/game-library/admin/profile/:profileId ── Admin löscht Profil + app.delete('/api/game-library/admin/profile/:profileId', requireAdmin, (req, res) => { + const data = loadData(ctx); + const profileId = String(req.params.profileId); + const profile = data.profiles[profileId]; + if (!profile) { res.status(404).json({ error: 'Profil nicht gefunden.' }); return; } + + const displayName = profile.displayName; + + if (profile.steamId) { + const stillUsed = Object.values(data.profiles).some(p => p.id !== profile.id && p.steamId === profile.steamId); + if (!stillUsed) delete data.users[profile.steamId]; + } + if (profile.gogUserId) { + const stillUsed = Object.values(data.profiles).some(p => p.id !== profile.id && p.gogUserId === profile.gogUserId); + if (!stillUsed && data.gogUsers) delete data.gogUsers[profile.gogUserId]; + } + + delete data.profiles[profileId]; + saveData(ctx, data); + broadcastUpdate(data); + + console.log(`[GameLibrary] Admin: Profil geloescht: ${displayName}`); + res.json({ ok: true, displayName }); + }); }, getSnapshot(ctx) { const data = loadData(ctx); return { 'game-library': { - users: Object.values(data.users).map(u => ({ - steamId: u.steamId, - personaName: u.personaName, - avatarUrl: u.avatarUrl, - gameCount: u.games.length, - lastUpdated: u.lastUpdated, - })), + profiles: buildProfileSummaries(data), }, }; }, diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx index 03157e2..a724355 100644 --- a/web/src/plugins/game-library/GameLibraryTab.tsx +++ b/web/src/plugins/game-library/GameLibraryTab.tsx @@ -109,6 +109,14 @@ 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(''); + // ── SSE data sync ── useEffect(() => { if (data?.profiles) setProfiles(data.profiles); @@ -125,6 +133,77 @@ 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(() => { const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600'); @@ -350,8 +429,39 @@ export default function GameLibraryTab({ data }: { data: any }) { }); }, []); + // ── Disconnect platform ── + const disconnectPlatform = useCallback(async (profileId: string, platform: 'steam' | 'gog') => { + if (!confirm(`${platform === 'steam' ? 'Steam' : 'GOG'}-Verknuepfung wirklich trennen?`)) return; + try { + const resp = await fetch(`/api/game-library/profile/${profileId}/${platform}`, { method: 'DELETE' }); + if (resp.ok) { + fetchProfiles(); + // If we disconnected the last platform, go back to overview + const result = await resp.json(); + if (result.ok) { + const p = profiles.find(pr => pr.id === profileId); + const otherPlatform = platform === 'steam' ? p?.platforms.gog : p?.platforms.steam; + if (!otherPlatform) goBackFn(); + } + } + } catch { /* silent */ } + }, [fetchProfiles, profiles]); + + // ── Delete profile ── + const deleteProfile = useCallback(async (profileId: string) => { + const p = profiles.find(pr => pr.id === profileId); + if (!confirm(`Profil "${p?.displayName}" wirklich komplett loeschen?`)) return; + try { + const resp = await fetch(`/api/game-library/profile/${profileId}`, { method: 'DELETE' }); + if (resp.ok) { + fetchProfiles(); + goBackFn(); + } + } catch { /* silent */ } + }, [fetchProfiles, profiles]); + // ── Back to overview ── - const goBack = useCallback(() => { + const goBackFn = useCallback(() => { setMode('overview'); setSelectedProfile(null); setUserGames(null); @@ -360,6 +470,7 @@ export default function GameLibraryTab({ data }: { data: any }) { setActiveGenres(new Set()); setSortBy('playtime'); }, []); + const goBack = goBackFn; // ── Resolve profile by id ── const getProfile = useCallback( @@ -438,6 +549,10 @@ export default function GameLibraryTab({ data }: { data: any }) { +
+
{/* ── Profile Chips ── */} @@ -611,13 +726,23 @@ export default function GameLibraryTab({ data }: { data: any }) {
{profile.platforms.steam && ( - - Steam ✓ + + Steam ✓ + )} {profile.platforms.gog ? ( - - GOG ✓ + + GOG ✓ + ) : ( +
+ + {!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 ce86c55..372c8f0 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -419,6 +419,31 @@ /* ── Link GOG Button ── */ +/* ── Disconnect Button ── */ + +.gl-platform-detail { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.gl-disconnect-btn { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 10px; + padding: 0 3px; + line-height: 1; + border-radius: 3px; + transition: all 0.2s; +} + +.gl-disconnect-btn:hover { + color: #e74c3c; + background: rgba(231, 76, 60, 0.15); +} + .gl-link-gog-btn { background: rgba(168, 85, 247, 0.15); color: #a855f7; @@ -847,3 +872,211 @@ opacity: 0.5; cursor: not-allowed; } + +/* ── Admin Panel ── */ + +.gl-login-bar-spacer { + flex: 1; +} + +.gl-admin-btn { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + color: #888; + padding: 8px 12px; + border-radius: var(--radius); + cursor: pointer; + font-size: 16px; + transition: all 0.2s; +} + +.gl-admin-btn:hover { + background: rgba(255,255,255,0.1); + color: #ccc; +} + +.gl-admin-panel { + background: #2a2a3e; + border-radius: 12px; + padding: 0; + max-width: 600px; + width: 92%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.gl-admin-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(255,255,255,0.08); +} + +.gl-admin-header h3 { + margin: 0; + font-size: 1.1rem; + color: #fff; +} + +.gl-admin-close { + background: none; + border: none; + color: #888; + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.gl-admin-close:hover { + color: #fff; + background: rgba(255,255,255,0.1); +} + +.gl-admin-login { + padding: 20px; +} + +.gl-admin-login p { + color: #aaa; + margin: 0 0 12px; + font-size: 14px; +} + +.gl-admin-login-row { + display: flex; + gap: 8px; +} + +.gl-admin-login-btn { + background: #e67e22; + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + white-space: nowrap; +} + +.gl-admin-login-btn:hover { + background: #d35400; +} + +.gl-admin-content { + padding: 0; + overflow-y: auto; +} + +.gl-admin-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.gl-admin-status-text { + font-size: 13px; + color: #4caf50; + flex: 1; +} + +.gl-admin-refresh-btn, +.gl-admin-logout-btn { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + color: #aaa; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; +} + +.gl-admin-refresh-btn:hover { + background: rgba(255,255,255,0.1); + color: #fff; +} + +.gl-admin-logout-btn:hover { + background: rgba(231, 76, 60, 0.15); + color: #e74c3c; + border-color: rgba(231, 76, 60, 0.3); +} + +.gl-admin-list { + padding: 8px 12px; +} + +.gl-admin-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 8px; + border-bottom: 1px solid rgba(255,255,255,0.04); + transition: background 0.15s; +} + +.gl-admin-item:last-child { + border-bottom: none; +} + +.gl-admin-item:hover { + background: rgba(255,255,255,0.03); +} + +.gl-admin-item-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + flex-shrink: 0; +} + +.gl-admin-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.gl-admin-item-name { + font-size: 14px; + font-weight: 600; + color: #e0e0e0; +} + +.gl-admin-item-details { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.gl-admin-item-total { + font-size: 11px; + color: #667; +} + +.gl-admin-delete-btn { + background: rgba(231, 76, 60, 0.1); + color: #e74c3c; + border: 1px solid rgba(231, 76, 60, 0.2); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + transition: all 0.2s; + flex-shrink: 0; +} + +.gl-admin-delete-btn:hover { + background: rgba(231, 76, 60, 0.25); + border-color: rgba(231, 76, 60, 0.4); +}