From 87b4467995a24f56f722eafcb1944ab37e994cca Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 7 Mar 2026 23:31:35 +0100 Subject: [PATCH] Feature: Game Library Plugin - Steam Spielebibliothek - Neues Plugin: game-library mit Steam OpenID 2.0 Login - Steam GetOwnedGames API zum Abrufen der Spielebibliothek - Gemeinsame Spiele finden (Schnittmenge mehrerer Bibliotheken) - Spielesuche ueber alle verbundenen User - User-Profil mit Spielzeit-Sortierung - JSON-basierte Persistenz in /data/game-library.json - Steam API Key als CI/CD Variable konfiguriert - Frontend: User Cards, Common Games Finder, Suchfunktion Co-Authored-By: Claude Opus 4.6 --- .gitlab-ci.yml | 1 + server/src/index.ts | 6 + server/src/plugins/game-library/index.ts | 419 ++++++++++++++ web/src/App.tsx | 3 + .../plugins/game-library/GameLibraryTab.tsx | 526 ++++++++++++++++++ web/src/plugins/game-library/game-library.css | 465 ++++++++++++++++ 6 files changed, 1420 insertions(+) create mode 100644 server/src/plugins/game-library/index.ts create mode 100644 web/src/plugins/game-library/GameLibraryTab.tsx create mode 100644 web/src/plugins/game-library/game-library.css diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index eb60bc5..738862d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -153,6 +153,7 @@ deploy: -e PCM_CACHE_MAX_MB=2048 \ -e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \ -e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \ + -e STEAM_API_KEY="$STEAM_API_KEY" \ -v /mnt/cache/appdata/gaming-hub/data:/data:rw \ -v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \ "$DEPLOY_IMAGE" diff --git a/server/src/index.ts b/server/src/index.ts index 9abcbae..240620c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,6 +13,7 @@ import soundboardPlugin from './plugins/soundboard/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js'; import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js'; import watchTogetherPlugin, { attachWatchTogetherWs } from './plugins/watch-together/index.js'; +import gameLibraryPlugin from './plugins/game-library/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); @@ -143,6 +144,11 @@ async function boot(): Promise { const ctxWatchTogether: PluginContext = { client: clientWatchTogether, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; registerPlugin(watchTogetherPlugin, ctxWatchTogether); + // game-library has no Discord bot — use a dummy client + const clientGameLibrary = createClient(); + const ctxGameLibrary: PluginContext = { client: clientGameLibrary, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; + registerPlugin(gameLibraryPlugin, ctxGameLibrary); + // Init all plugins for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name)!; diff --git a/server/src/plugins/game-library/index.ts b/server/src/plugins/game-library/index.ts new file mode 100644 index 0000000..d085fa0 --- /dev/null +++ b/server/src/plugins/game-library/index.ts @@ -0,0 +1,419 @@ +import type express from 'express'; +import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { sseBroadcast } from '../../core/sse.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +// ── Types ── + +interface SteamGame { + appid: number; + name: string; + playtime_forever: number; // minutes + img_icon_url: string; +} + +interface SteamUser { + steamId: string; + personaName: string; + avatarUrl: string; + profileUrl: string; + games: SteamGame[]; + lastUpdated: string; // ISO date +} + +interface GameLibraryData { + users: Record; // keyed by steamId +} + +// ── Constants ── + +const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166DF32'; + +// ── Data Persistence ── + +function getDataPath(ctx: PluginContext): string { + return `${ctx.dataDir}/game-library.json`; +} + +function loadData(ctx: PluginContext): GameLibraryData { + try { + const raw = fs.readFileSync(getDataPath(ctx), 'utf-8'); + return JSON.parse(raw) as GameLibraryData; + } catch { + const empty: GameLibraryData = { users: {} }; + saveData(ctx, empty); + return empty; + } +} + +function saveData(ctx: PluginContext, data: GameLibraryData): void { + try { + const dir = path.dirname(getDataPath(ctx)); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(getDataPath(ctx), JSON.stringify(data, null, 2), 'utf-8'); + } catch (err) { + console.error('[GameLibrary] Failed to save data:', err); + } +} + +// ── SSE Broadcast ── + +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, + })); + sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users }); +} + +// ── Steam API Helpers ── + +async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string; profileUrl: string }> { + const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`; + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`Steam API error: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json() as any; + const player = json?.response?.players?.[0]; + if (!player) { + throw new Error(`Steam user not found: ${steamId}`); + } + return { + personaName: player.personaname || steamId, + avatarUrl: player.avatarfull || '', + profileUrl: player.profileurl || '', + }; +} + +async function fetchSteamGames(steamId: string): Promise { + const url = `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=${STEAM_API_KEY}&steamid=${steamId}&include_appinfo=true&include_played_free_games=true&format=json`; + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`Steam API error: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json() as any; + const games: SteamGame[] = (json?.response?.games || []).map((g: any) => ({ + appid: g.appid, + name: g.name || '', + playtime_forever: g.playtime_forever || 0, + img_icon_url: g.img_icon_url || '', + })); + return games; +} + +// ── Plugin ── + +const gameLibraryPlugin: Plugin = { + name: 'game-library', + version: '1.0.0', + description: 'Steam Spielebibliothek', + + async init(ctx) { + loadData(ctx); // ensure file exists + console.log('[GameLibrary] Initialized'); + }, + + registerRoutes(app: express.Application, ctx: PluginContext) { + // ── Steam OpenID Login ── + app.get('/api/game-library/steam/login', (_req, res) => { + const proto = _req.headers['x-forwarded-proto'] || _req.protocol || 'http'; + const host = _req.headers['x-forwarded-host'] || _req.headers.host || 'localhost'; + const realm = `${proto}://${host}`; + const returnTo = `${realm}/api/game-library/steam/callback`; + + const params = new URLSearchParams({ + 'openid.ns': 'http://specs.openid.net/auth/2.0', + 'openid.mode': 'checkid_setup', + 'openid.return_to': returnTo, + 'openid.realm': realm, + 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', + 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', + }); + + res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`); + }); + + // ── Steam OpenID Callback ── + app.get('/api/game-library/steam/callback', async (req, res) => { + try { + const claimedId = String(req.query['openid.claimed_id'] || ''); + const steamIdMatch = claimedId.match(/\/id\/(\d+)$/); + if (!steamIdMatch) { + res.status(400).send(errorPage('Steam-Authentifizierung fehlgeschlagen', 'Keine gueltige Steam-ID erhalten.')); + return; + } + const steamId = steamIdMatch[1]; + + // Verify authentication with Steam + const verifyParams = new URLSearchParams(); + for (const [key, value] of Object.entries(req.query)) { + verifyParams.set(key, String(value)); + } + verifyParams.set('openid.mode', 'check_authentication'); + + const verifyResp = await fetch(`https://steamcommunity.com/openid/login?${verifyParams.toString()}`); + const verifyText = await verifyResp.text(); + + if (!verifyText.includes('is_valid:true')) { + res.status(403).send(errorPage('Verifizierung fehlgeschlagen', 'Steam konnte die Anmeldung nicht verifizieren.')); + return; + } + + // Fetch profile and games in parallel + const [profile, games] = await Promise.all([ + fetchSteamProfile(steamId), + fetchSteamGames(steamId), + ]); + + // Store user data + const data = loadData(ctx); + data.users[steamId] = { + steamId, + personaName: profile.personaName, + avatarUrl: profile.avatarUrl, + profileUrl: profile.profileUrl, + games, + lastUpdated: new Date().toISOString(), + }; + saveData(ctx, data); + broadcastUpdate(data); + + const personaName = profile.personaName; + console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`); + + res.send(`Steam verbunden

Steam verbunden!

${personaName} wurde erfolgreich verknuepft.

${games.length} Spiele geladen.

Du kannst dieses Fenster schliessen.

`); + } catch (err) { + console.error('[GameLibrary] Callback error:', err); + res.status(500).send(errorPage('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.')); + } + }); + + // ── GET /api/game-library/users ── + app.get('/api/game-library/users', (_req, res) => { + const data = loadData(ctx); + const users = Object.values(data.users).map(u => ({ + steamId: u.steamId, + personaName: u.personaName, + avatarUrl: u.avatarUrl, + gameCount: u.games.length, + lastUpdated: u.lastUpdated, + })); + res.json({ users }); + }); + + // ── GET /api/game-library/user/:steamId ── + app.get('/api/game-library/user/:steamId', (req, res) => { + const data = loadData(ctx); + const user = data.users[req.params.steamId]; + if (!user) { + res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + return; + } + // Sort games by playtime descending + const sortedGames = [...user.games].sort((a, b) => b.playtime_forever - a.playtime_forever); + res.json({ + steamId: user.steamId, + personaName: user.personaName, + avatarUrl: user.avatarUrl, + profileUrl: user.profileUrl, + gameCount: sortedGames.length, + lastUpdated: user.lastUpdated, + games: sortedGames, + }); + }); + + // ── GET /api/game-library/common-games?users=id1,id2,... ── + app.get('/api/game-library/common-games', (req, res) => { + const userIds = String(req.query.users || '').split(',').filter(Boolean); + if (userIds.length < 2) { + res.status(400).json({ error: 'Mindestens zwei Steam-IDs erforderlich (users=id1,id2).' }); + return; + } + + const data = loadData(ctx); + + // Validate all users exist + for (const id of userIds) { + if (!data.users[id]) { + res.status(404).json({ error: `Benutzer ${id} nicht gefunden.` }); + return; + } + } + + // Build game sets per user: Map + const userGameMaps = userIds.map(id => { + const map = new Map(); + for (const game of data.users[id].games) { + map.set(game.appid, game); + } + return { userId: id, gameMap: map }; + }); + + // Find intersection: games present in ALL users + const firstUserGames = userGameMaps[0].gameMap; + const commonAppIds: number[] = []; + + for (const appid of firstUserGames.keys()) { + const allOwn = userGameMaps.every(u => u.gameMap.has(appid)); + if (allOwn) { + commonAppIds.push(appid); + } + } + + // Build result with owner info + const games = commonAppIds.map(appid => { + const refGame = firstUserGames.get(appid)!; + const owners = userIds.map(id => { + const user = data.users[id]; + const userGame = user.games.find(g => g.appid === appid); + return { + steamId: user.steamId, + personaName: user.personaName, + playtime_forever: userGame?.playtime_forever || 0, + }; + }); + return { + appid: refGame.appid, + name: refGame.name, + img_icon_url: refGame.img_icon_url, + owners, + }; + }); + + // Sort by name + games.sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ games, count: games.length }); + }); + + // ── GET /api/game-library/search?q=term ── + app.get('/api/game-library/search', (req, res) => { + const query = String(req.query.q || '').trim().toLowerCase(); + if (!query) { + res.status(400).json({ error: 'Suchbegriff erforderlich (q=...).' }); + return; + } + + const data = loadData(ctx); + const allUsers = Object.values(data.users); + + // Collect all unique games matching the query + const gameMap = new Map(); + + for (const user of allUsers) { + for (const game of user.games) { + if (game.name.toLowerCase().includes(query)) { + if (!gameMap.has(game.appid)) { + gameMap.set(game.appid, { + appid: game.appid, + name: game.name, + img_icon_url: game.img_icon_url, + owners: [], + }); + } + gameMap.get(game.appid)!.owners.push({ + steamId: user.steamId, + personaName: user.personaName, + }); + } + } + } + + const games = [...gameMap.values()].sort((a, b) => a.name.localeCompare(b.name)); + res.json({ games, count: games.length }); + }); + + // ── POST /api/game-library/refresh/:steamId ── + app.post('/api/game-library/refresh/:steamId', async (req, res) => { + try { + const { steamId } = req.params; + const data = loadData(ctx); + const user = data.users[steamId]; + if (!user) { + res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + return; + } + + // Re-fetch profile and games in parallel + const [profile, games] = await Promise.all([ + fetchSteamProfile(steamId), + fetchSteamGames(steamId), + ]); + + data.users[steamId] = { + steamId, + personaName: profile.personaName, + avatarUrl: profile.avatarUrl, + profileUrl: profile.profileUrl, + games, + lastUpdated: new Date().toISOString(), + }; + saveData(ctx, data); + broadcastUpdate(data); + + console.log(`[GameLibrary] Aktualisiert: ${profile.personaName} (${steamId}) - ${games.length} Spiele`); + + res.json({ + steamId, + personaName: profile.personaName, + avatarUrl: profile.avatarUrl, + profileUrl: profile.profileUrl, + gameCount: games.length, + lastUpdated: data.users[steamId].lastUpdated, + }); + } catch (err) { + console.error('[GameLibrary] Refresh error:', err); + res.status(500).json({ error: 'Fehler beim Aktualisieren der Spielebibliothek.' }); + } + }); + + // ── DELETE /api/game-library/user/:steamId ── + app.delete('/api/game-library/user/:steamId', (req, res) => { + const { steamId } = req.params; + const data = loadData(ctx); + const user = data.users[steamId]; + if (!user) { + res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + return; + } + + const personaName = user.personaName; + delete data.users[steamId]; + saveData(ctx, data); + broadcastUpdate(data); + + console.log(`[GameLibrary] Benutzer entfernt: ${personaName} (${steamId})`); + res.json({ success: true, message: `${personaName} wurde entfernt.` }); + }); + }, + + 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, + })), + }, + }; + }, +}; + +// ── Helper: Error HTML Page ── + +function errorPage(title: string, message: string): string { + return `${title}

${title}

${message}

Du kannst dieses Fenster schliessen.

`; +} + +export default gameLibraryPlugin; diff --git a/web/src/App.tsx b/web/src/App.tsx index 7700f46..8c8d132 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import SoundboardTab from './plugins/soundboard/SoundboardTab'; 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'; interface PluginInfo { name: string; @@ -18,6 +19,7 @@ const tabComponents: Record> = { lolstats: LolstatsTab, streaming: StreamingTab, 'watch-together': WatchTogetherTab, + 'game-library': GameLibraryTab, }; export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { @@ -122,6 +124,7 @@ export default function App() { gamevote: '\u{1F3AE}', streaming: '\u{1F4FA}', 'watch-together': '\u{1F3AC}', + 'game-library': '\u{1F3AE}', }; return ( diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx new file mode 100644 index 0000000..b8a7027 --- /dev/null +++ b/web/src/plugins/game-library/GameLibraryTab.tsx @@ -0,0 +1,526 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import './game-library.css'; + +/* ══════════════════════════════════════════════════════════════════ + TYPES + ══════════════════════════════════════════════════════════════════ */ + +interface UserSummary { + steamId: string; + personaName: string; + avatarUrl: string; + gameCount: number; + lastUpdated: string; +} + +interface SteamGame { + appid: number; + name: string; + playtime_forever: number; + img_icon_url: string; +} + +interface CommonGame { + appid: number; + name: string; + img_icon_url: string; + owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>; +} + +interface SearchResult { + appid: number; + name: string; + img_icon_url: string; + owners: Array<{ steamId: string; personaName: string }>; +} + +/* ══════════════════════════════════════════════════════════════════ + HELPERS + ══════════════════════════════════════════════════════════════════ */ + +function gameIconUrl(appid: number, hash: string): string { + return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`; +} + +function formatPlaytime(minutes: number): string { + if (minutes < 60) return `${minutes} Min`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return iso; + } +} + +/* ══════════════════════════════════════════════════════════════════ + COMPONENT + ══════════════════════════════════════════════════════════════════ */ + +export default function GameLibraryTab({ data }: { data: any }) { + // ── State ── + const [users, setUsers] = useState([]); + const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedUsers, setSelectedUsers] = useState>(new Set()); + const [userGames, setUserGames] = useState(null); + const [commonGames, setCommonGames] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [loading, setLoading] = useState(false); + + const searchTimerRef = useRef | null>(null); + const filterInputRef = useRef(null); + const [filterQuery, setFilterQuery] = useState(''); + + // ── SSE data sync ── + useEffect(() => { + if (data?.users) setUsers(data.users); + }, [data]); + + // ── Refetch users ── + const fetchUsers = useCallback(async () => { + try { + const resp = await fetch('/api/game-library/users'); + if (resp.ok) { + const d = await resp.json(); + setUsers(d.users || []); + } + } catch { + /* silent */ + } + }, []); + + // ── Steam login ── + const connectSteam = useCallback(() => { + const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600'); + const interval = setInterval(() => { + if (w && w.closed) { + clearInterval(interval); + setTimeout(fetchUsers, 1000); + } + }, 500); + }, [fetchUsers]); + + // ── Refetch on window focus (after login redirect) ── + useEffect(() => { + const onFocus = () => fetchUsers(); + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [fetchUsers]); + + // ── View user library ── + const viewUser = useCallback(async (steamId: string) => { + setMode('user'); + setSelectedUser(steamId); + setUserGames(null); + setFilterQuery(''); + setLoading(true); + try { + const resp = await fetch(`/api/game-library/user/${steamId}`); + if (resp.ok) { + const d = await resp.json(); + setUserGames(d.games || d); + } + } catch { + /* silent */ + } finally { + setLoading(false); + } + }, []); + + // ── Refresh single user ── + const refreshUser = useCallback(async (steamId: string, e?: React.MouseEvent) => { + if (e) e.stopPropagation(); + try { + await fetch(`/api/game-library/user/${steamId}?refresh=true`); + await fetchUsers(); + if (mode === 'user' && selectedUser === steamId) { + viewUser(steamId); + } + } catch { + /* silent */ + } + }, [fetchUsers, mode, selectedUser, viewUser]); + + // ── Toggle user selection for common games ── + const toggleCommonUser = useCallback((steamId: string) => { + setSelectedUsers(prev => { + const next = new Set(prev); + if (next.has(steamId)) next.delete(steamId); + else next.add(steamId); + return next; + }); + }, []); + + // ── Find common games ── + const findCommonGames = useCallback(async () => { + if (selectedUsers.size < 2) return; + setMode('common'); + setCommonGames(null); + setLoading(true); + try { + const ids = Array.from(selectedUsers).join(','); + const resp = await fetch(`/api/game-library/common-games?users=${ids}`); + if (resp.ok) { + const d = await resp.json(); + setCommonGames(d.games || d); + } + } catch { + /* silent */ + } finally { + setLoading(false); + } + }, [selectedUsers]); + + // ── Search (debounced) ── + const handleSearch = useCallback((value: string) => { + setSearchQuery(value); + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + if (value.length < 2) { + setSearchResults(null); + return; + } + searchTimerRef.current = setTimeout(async () => { + try { + const resp = await fetch(`/api/game-library/search?q=${encodeURIComponent(value)}`); + if (resp.ok) { + const d = await resp.json(); + setSearchResults(d.results || d); + } + } catch { + /* silent */ + } + }, 300); + }, []); + + // ── Back to overview ── + const goBack = useCallback(() => { + setMode('overview'); + setSelectedUser(null); + setUserGames(null); + setCommonGames(null); + setFilterQuery(''); + }, []); + + // ── Resolve user by steamId ── + const getUser = useCallback( + (steamId: string) => users.find(u => u.steamId === steamId), + [users], + ); + + // ── Filtered user games ── + const filteredGames = userGames + ? userGames + .filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase())) + .sort((a, b) => b.playtime_forever - a.playtime_forever) + : null; + + /* ════════════════════════════════════════════════════════════════ + RENDER + ════════════════════════════════════════════════════════════════ */ + + return ( +
+ {/* ── Top bar ── */} +
+ +
+ {users.map(u => ( +
viewUser(u.steamId)} + > + {u.personaName} + {u.personaName} + ({u.gameCount}) + +
+ ))} +
+
+ + {/* ── Overview mode ── */} + {mode === 'overview' && ( + <> + {users.length === 0 ? ( +
+
🎮
+

Keine Steam-Konten verbunden

+

+ Klicke oben auf “Mit Steam verbinden”, um deine Spielebibliothek + hinzuzufuegen. +

+
+ ) : ( + <> + {/* User cards */} +

Verbundene Spieler

+
+ {users.map(u => ( +
viewUser(u.steamId)}> + {u.personaName} + {u.personaName} + {u.gameCount} Spiele + + Aktualisiert: {formatDate(u.lastUpdated)} + +
+ ))} +
+ + {/* Common games finder */} + {users.length >= 2 && ( +
+

Gemeinsame Spiele finden

+
+ {users.map(u => ( + + ))} +
+ +
+ )} + + {/* Search */} +
+ handleSearch(e.target.value)} + /> +
+ + {/* Search results */} + {searchResults && searchResults.length > 0 && ( + <> +

+ {searchResults.length} Ergebnis{searchResults.length !== 1 ? 'se' : ''} +

+
+ {searchResults.map(g => ( +
+ {g.img_icon_url ? ( + + ) : ( +
+ )} + {g.name} +
+ {g.owners.map(o => { + const u = getUser(o.steamId); + return u ? ( + {o.personaName} + ) : null; + })} +
+
+ ))} +
+ + )} + + {searchResults && searchResults.length === 0 && ( +

Keine Ergebnisse gefunden.

+ )} + + )} + + )} + + {/* ── User mode ── */} + {mode === 'user' && (() => { + const user = selectedUser ? getUser(selectedUser) : null; + return ( + <> +
+ + {user && ( + <> + {user.personaName} +
+
+ {user.personaName} + {user.gameCount} Spiele +
+
+ Aktualisiert: {formatDate(user.lastUpdated)} +
+
+ + + )} +
+ + {loading ? ( +
Bibliothek wird geladen...
+ ) : filteredGames ? ( + <> +
+ setFilterQuery(e.target.value)} + /> +
+ {filteredGames.length === 0 ? ( +

Keine Spiele gefunden.

+ ) : ( +
+ {filteredGames.map(g => ( +
+ {g.img_icon_url ? ( + + ) : ( +
+ )} + {g.name} + + {formatPlaytime(g.playtime_forever)} + +
+ ))} +
+ )} + + ) : null} + + ); + })()} + + {/* ── Common mode ── */} + {mode === 'common' && (() => { + const selected = Array.from(selectedUsers) + .map(id => getUser(id)) + .filter(Boolean) as UserSummary[]; + const names = selected.map(u => u.personaName).join(', '); + return ( + <> +
+ +
+ {selected.map(u => ( + {u.personaName} + ))} +
+
+
Gemeinsame Spiele
+
von {names}
+
+
+ + {loading ? ( +
Gemeinsame Spiele werden gesucht...
+ ) : commonGames ? ( + commonGames.length === 0 ? ( +
+
😔
+

Keine gemeinsamen Spiele

+

Die ausgewaehlten Spieler besitzen leider keine gemeinsamen Spiele.

+
+ ) : ( + <> +

+ {commonGames.length} gemeinsame{commonGames.length !== 1 ? ' Spiele' : 's Spiel'} +

+
+ {commonGames.map(g => ( +
+ {g.img_icon_url ? ( + + ) : ( +
+ )} + {g.name} +
+ {g.owners.map(o => ( + + {o.personaName}: {formatPlaytime(o.playtime_forever)} + + ))} +
+
+ ))} +
+ + ) + ) : null} + + ); + })()} +
+ ); +} diff --git a/web/src/plugins/game-library/game-library.css b/web/src/plugins/game-library/game-library.css new file mode 100644 index 0000000..3ab830e --- /dev/null +++ b/web/src/plugins/game-library/game-library.css @@ -0,0 +1,465 @@ +/* ══════════════════════════════════════════════════════════════════ + Game Library – Plugin Styles + ══════════════════════════════════════════════════════════════════ */ + +/* Container */ +.gl-container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +/* ── Top bar ── */ + +.gl-topbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.gl-connect-btn { + background: #1b2838; + color: #c7d5e0; + border: 1px solid #2a475e; + padding: 10px 20px; + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + transition: all var(--transition); + white-space: nowrap; +} + +.gl-connect-btn:hover { + background: #2a475e; + color: #fff; +} + +/* ── User chips (top bar) ── */ + +.gl-user-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + flex: 1; +} + +.gl-user-chip { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-secondary); + padding: 4px 10px 4px 4px; + border-radius: 20px; + cursor: pointer; + transition: all var(--transition); + border: 1px solid transparent; +} + +.gl-user-chip:hover { + border-color: var(--accent); +} + +.gl-user-chip.selected { + border-color: var(--accent); + background: rgba(230, 126, 34, 0.12); +} + +.gl-user-chip-avatar { + width: 28px; + height: 28px; + border-radius: 50%; +} + +.gl-user-chip-name { + font-size: 13px; + color: var(--text-normal); +} + +.gl-user-chip-count { + font-size: 11px; + color: var(--text-faint); +} + +.gl-user-chip-refresh { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-size: 13px; + padding: 2px; + margin-left: 2px; + line-height: 1; + transition: color var(--transition); +} + +.gl-user-chip-refresh:hover { + color: var(--accent); +} + +/* ── User cards grid (overview) ── */ + +.gl-users-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 32px; +} + +.gl-user-card { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + cursor: pointer; + transition: all var(--transition); + border: 1px solid var(--bg-tertiary); +} + +.gl-user-card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.gl-user-card-avatar { + width: 64px; + height: 64px; + border-radius: 50%; +} + +.gl-user-card-name { + font-weight: 600; + font-size: 15px; + color: var(--text-normal); +} + +.gl-user-card-games { + font-size: 13px; + color: var(--text-faint); +} + +.gl-user-card-updated { + font-size: 11px; + color: var(--text-faint); +} + +/* ── Common games finder ── */ + +.gl-common-finder { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 24px; +} + +.gl-common-finder h3 { + margin: 0 0 12px; + font-size: 15px; + color: var(--text-normal); +} + +.gl-common-users { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.gl-common-check { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-tertiary); + padding: 6px 12px 6px 6px; + border-radius: 20px; + cursor: pointer; + transition: all var(--transition); +} + +.gl-common-check.checked { + background: rgba(230, 126, 34, 0.15); +} + +.gl-common-check input { + accent-color: var(--accent); +} + +.gl-common-check-avatar { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.gl-common-find-btn { + background: var(--accent); + color: #fff; + border: none; + padding: 10px 24px; + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + transition: background var(--transition); +} + +.gl-common-find-btn:hover { + filter: brightness(1.1); +} + +.gl-common-find-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Search ── */ + +.gl-search { + margin-bottom: 24px; +} + +.gl-search-input { + width: 100%; + padding: 10px 14px; + background: var(--bg-secondary); + border: 1px solid var(--bg-tertiary); + border-radius: var(--radius); + color: var(--text-normal); + font-size: 14px; + outline: none; + transition: border-color var(--transition); + box-sizing: border-box; +} + +.gl-search-input:focus { + border-color: var(--accent); +} + +.gl-search-input::placeholder { + color: var(--text-faint); +} + +/* ── Game list ── */ + +.gl-game-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.gl-game-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-secondary); + border-radius: var(--radius); + transition: background var(--transition); +} + +.gl-game-item:hover { + background: var(--bg-tertiary); +} + +.gl-game-icon { + width: 32px; + height: 32px; + border-radius: 4px; + flex-shrink: 0; + background: var(--bg-tertiary); +} + +.gl-game-name { + flex: 1; + font-size: 14px; + color: var(--text-normal); +} + +.gl-game-playtime { + font-size: 12px; + color: var(--text-faint); + white-space: nowrap; +} + +.gl-game-owners { + display: flex; + gap: 4px; +} + +.gl-game-owner-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--bg-tertiary); +} + +/* ── Detail header (user view / common view) ── */ + +.gl-detail-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.gl-back-btn { + background: var(--bg-secondary); + border: none; + color: var(--text-normal); + padding: 8px 14px; + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; + transition: background var(--transition); +} + +.gl-back-btn:hover { + background: var(--bg-tertiary); +} + +.gl-detail-avatar { + width: 48px; + height: 48px; + border-radius: 50%; +} + +.gl-detail-info { + flex: 1; +} + +.gl-detail-name { + font-size: 18px; + font-weight: 600; + color: var(--text-normal); +} + +.gl-detail-sub { + font-size: 13px; + color: var(--text-faint); +} + +.gl-refresh-btn { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-size: 16px; + padding: 4px; + transition: color var(--transition); +} + +.gl-refresh-btn:hover { + color: var(--accent); +} + +/* ── Loading ── */ + +.gl-loading { + text-align: center; + padding: 48px; + color: var(--text-faint); + font-size: 14px; +} + +/* ── Empty state ── */ + +.gl-empty { + text-align: center; + padding: 60px 20px; +} + +.gl-empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.gl-empty h3 { + color: var(--text-normal); + margin: 0 0 8px; +} + +.gl-empty p { + color: var(--text-faint); + margin: 0; + font-size: 14px; +} + +/* ── Common game playtime chips ── */ + +.gl-common-playtimes { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.gl-common-pt { + font-size: 11px; + color: var(--text-faint); + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 4px; +} + +/* ── Section title ── */ + +.gl-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + margin: 0 0 12px; +} + +/* ── Game count badge ── */ + +.gl-game-count { + font-size: 12px; + color: var(--text-faint); + margin-left: 8px; + font-weight: 400; +} + +/* ── Common view avatars in header ── */ + +.gl-detail-avatars { + display: flex; + gap: -8px; +} + +.gl-detail-avatars img { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--bg-primary); + margin-left: -8px; +} + +.gl-detail-avatars img:first-child { + margin-left: 0; +} + +/* ── Search results section title ── */ + +.gl-search-results-title { + font-size: 13px; + color: var(--text-faint); + margin-bottom: 8px; +} + +/* ── Responsive ── */ + +@media (max-width: 768px) { + .gl-container { + padding: 12px; + } + + .gl-users-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .gl-topbar { + flex-direction: column; + align-items: stretch; + } +}