diff --git a/server/src/plugins/game-library/gog.ts b/server/src/plugins/game-library/gog.ts new file mode 100644 index 0000000..3caf794 --- /dev/null +++ b/server/src/plugins/game-library/gog.ts @@ -0,0 +1,288 @@ +// ────────────────────────────────────────────────────────────────────────────── +// GOG (Good Old Games) API service +// Uses GOG OAuth2 for user authentication. All paginated requests include a +// 500 ms pause between pages to respect rate-limits. +// ────────────────────────────────────────────────────────────────────────────── + +const GOG_CLIENT_ID = '46899977096215655'; +const GOG_CLIENT_SECRET = + '9d85c43b1482497dbbce61f6e4aa173a433796eebd2c1f0f7f015c4c2e57571'; +const GOG_AUTH_URL = 'https://auth.gog.com/auth'; +const GOG_TOKEN_URL = 'https://auth.gog.com/token'; +const GOG_EMBED_URL = 'https://embed.gog.com'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface GogTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // unix-ms + userId: string; +} + +export interface GogUserInfo { + userId: string; + username: string; + avatarUrl: string; +} + +export interface GogGame { + gogId: number; + title: string; + image: string; // cover URL from GOG + slug: string; +} + +// ── OAuth helpers ──────────────────────────────────────────────────────────── + +/** + * Build the GOG OAuth authorization URL that the user should visit to grant + * access. After approval, GOG will redirect to `redirectUri` with a `code` + * query parameter. + */ +export function getGogAuthUrl(redirectUri: string): string { + const params = new URLSearchParams({ + client_id: GOG_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + layout: 'client2', + }); + + return `${GOG_AUTH_URL}?${params.toString()}`; +} + +/** + * Exchange an authorization code for GOG access + refresh tokens. + */ +export async function exchangeGogCode( + code: string, + redirectUri: string, +): Promise { + console.log('[GOG] Exchanging authorization code for tokens...'); + + const params = new URLSearchParams({ + client_id: GOG_CLIENT_ID, + client_secret: GOG_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }); + + const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`); + + if (!res.ok) { + const text = await res.text(); + console.error(`[GOG] Token exchange failed (${res.status}): ${text}`); + throw new Error(`[GOG] Token exchange failed (${res.status}): ${text}`); + } + + const data = (await res.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + user_id: string; + token_type: string; + }; + + const tokens: GogTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + userId: data.user_id, + }; + + console.log( + `[GOG] Tokens acquired for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`, + ); + + return tokens; +} + +/** + * Obtain a fresh set of tokens using an existing refresh token. + */ +export async function refreshGogToken( + refreshToken: string, +): Promise { + console.log('[GOG] Refreshing access token...'); + + const params = new URLSearchParams({ + client_id: GOG_CLIENT_ID, + client_secret: GOG_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`); + + if (!res.ok) { + const text = await res.text(); + console.error(`[GOG] Token refresh failed (${res.status}): ${text}`); + throw new Error(`[GOG] Token refresh failed (${res.status}): ${text}`); + } + + const data = (await res.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + user_id: string; + token_type: string; + }; + + const tokens: GogTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + userId: data.user_id, + }; + + console.log( + `[GOG] Token refreshed for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`, + ); + + return tokens; +} + +// ── User info ──────────────────────────────────────────────────────────────── + +/** + * Fetch basic user profile information from the GOG embed API. + */ +export async function fetchGogUserInfo( + accessToken: string, +): Promise { + console.log('[GOG] Fetching user info...'); + + const res = await fetch(`${GOG_EMBED_URL}/userData.json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[GOG] User info request failed (${res.status}): ${text}`); + throw new Error(`[GOG] User info request failed (${res.status}): ${text}`); + } + + const data = (await res.json()) as { + userId: string; + username: string; + galaxyUserId: string; + avatar: string; + }; + + const userInfo: GogUserInfo = { + userId: data.galaxyUserId || data.userId, + username: data.username, + avatarUrl: data.avatar ? `https:${data.avatar}` : '', + }; + + console.log(`[GOG] User info fetched: ${userInfo.username}`); + + return userInfo; +} + +// ── Game library ───────────────────────────────────────────────────────────── + +/** + * Fetch the complete list of owned GOG games for the authenticated user. + * + * The GOG embed API paginates results (one page at a time). We iterate + * through all pages with a 500 ms pause between requests to stay friendly. + */ +export async function fetchGogGames( + accessToken: string, +): Promise { + console.log('[GOG] Fetching game library...'); + + const allGames: GogGame[] = []; + let currentPage = 1; + let totalPages = 1; + + do { + const url = `${GOG_EMBED_URL}/account/getFilteredProducts?mediaType=1&page=${currentPage}`; + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error( + `[GOG] Game library request failed on page ${currentPage} (${res.status}): ${text}`, + ); + throw new Error( + `[GOG] Game library request failed (${res.status}): ${text}`, + ); + } + + const data = (await res.json()) as { + totalPages: number; + products: Array<{ + id: number; + title: string; + image: string; + slug: string; + }>; + }; + + totalPages = data.totalPages; + + for (const product of data.products) { + allGames.push({ + gogId: product.id, + title: product.title, + image: product.image ? `https:${product.image}` : '', + slug: product.slug, + }); + } + + console.log( + `[GOG] Page ${currentPage}/${totalPages} fetched (${data.products.length} games)`, + ); + + currentPage++; + + // Rate-limit: 500 ms between page requests + if (currentPage <= totalPages) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } while (currentPage <= totalPages); + + console.log(`[GOG] Library complete: ${allGames.length} games total`); + + return allGames; +} + +// ── Cover URL helper ───────────────────────────────────────────────────────── + +/** + * Convert a GOG image base path into a full HTTPS cover URL. + * + * GOG images are returned as protocol-relative paths like + * `//images-1.gog.com/xxx`. This helper ensures HTTPS and optionally + * appends a size suffix (e.g. `_196.jpg`). + * + * @param imageBase The raw image path returned by the GOG API + * @param size Optional size suffix to append (e.g. `_196.jpg`) + */ +export function gogCoverUrl(imageBase: string, size?: string): string { + let url = imageBase; + + // Ensure HTTPS prefix + if (url.startsWith('//')) { + url = `https:${url}`; + } else if (!url.startsWith('http')) { + url = `https://${url}`; + } + + // Append size suffix if provided + if (size) { + url = `${url}${size}`; + } + + return url; +} diff --git a/server/src/plugins/game-library/index.ts b/server/src/plugins/game-library/index.ts index 2812830..86968af 100644 --- a/server/src/plugins/game-library/index.ts +++ b/server/src/plugins/game-library/index.ts @@ -3,7 +3,9 @@ import type { Plugin, PluginContext } from '../../core/plugin.js'; 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 { getGogAuthUrl, exchangeGogCode, refreshGogToken, fetchGogUserInfo, fetchGogGames, type GogGame } from './gog.js'; // ── Types ── @@ -24,8 +26,30 @@ interface SteamUser { lastUpdated: string; // ISO date } +interface GogUserData { + gogUserId: string; + username: string; + avatarUrl: string; + games: Array<{ gogId: number; title: string; image: string; slug: string; igdb?: IgdbGameInfo }>; + accessToken: string; + refreshToken: string; + tokenExpiresAt: number; + lastUpdated: string; +} + +interface UserProfile { + id: string; + displayName: string; + avatarUrl: string; + steamId?: string; + gogUserId?: string; + lastUpdated: string; +} + interface GameLibraryData { - users: Record; // keyed by steamId + users: Record; // keyed by steamId + gogUsers: Record; // keyed by gogUserId + profiles: Record; // keyed by profileId } // ── Constants ── @@ -43,7 +67,7 @@ function loadData(ctx: PluginContext): GameLibraryData { const raw = fs.readFileSync(getDataPath(ctx), 'utf-8'); return JSON.parse(raw) as GameLibraryData; } catch { - const empty: GameLibraryData = { users: {} }; + const empty: GameLibraryData = { users: {}, gogUsers: {}, profiles: {} }; saveData(ctx, empty); return empty; } @@ -71,7 +95,16 @@ function broadcastUpdate(data: GameLibraryData): void { gameCount: u.games.length, lastUpdated: u.lastUpdated, })); - sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users }); + 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 }); } // ── Steam API Helpers ── @@ -119,6 +152,27 @@ const gameLibraryPlugin: Plugin = { async init(ctx) { const data = loadData(ctx); // ensure file exists + + // Migrate: ensure gogUsers and profiles exist + if (!data.profiles) data.profiles = {}; + if (!data.gogUsers) data.gogUsers = {}; + + // Migrate existing Steam users to profiles + const existingProfileSteamIds = new Set(Object.values(data.profiles).map(p => p.steamId).filter(Boolean)); + for (const user of Object.values(data.users)) { + if (!existingProfileSteamIds.has(user.steamId)) { + const profileId = crypto.randomUUID(); + data.profiles[profileId] = { + id: profileId, + displayName: user.personaName, + avatarUrl: user.avatarUrl, + steamId: user.steamId, + lastUpdated: user.lastUpdated, + }; + } + } + saveData(ctx, data); + console.log('[GameLibrary] Initialized'); // Fire-and-forget: auto-enrich all existing users with IGDB data @@ -217,6 +271,21 @@ const gameLibraryPlugin: Plugin = { games, lastUpdated: new Date().toISOString(), }; + + // Create or update profile for this Steam user + if (!data.profiles) data.profiles = {}; + const existingProfile = Object.values(data.profiles).find(p => p.steamId === steamId); + if (!existingProfile) { + const profileId = crypto.randomUUID(); + data.profiles[profileId] = { + id: profileId, + displayName: profile.personaName, + avatarUrl: profile.avatarUrl, + steamId, + lastUpdated: new Date().toISOString(), + }; + } + saveData(ctx, data); broadcastUpdate(data); @@ -523,6 +592,173 @@ 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 }); + }); + + // ── GOG Login ── + app.get('/api/game-library/gog/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 redirectUri = `${realm}/api/game-library/gog/callback`; + const linkTo = req.query.linkTo ? `&state=${req.query.linkTo}` : ''; + res.redirect(getGogAuthUrl(redirectUri) + linkTo); + }); + + // ── GOG Callback ── + app.get('/api/game-library/gog/callback', async (req, res) => { + try { + const code = String(req.query.code || ''); + const linkToProfileId = String(req.query.state || ''); + if (!code) { + res.status(400).send(errorPage('GOG-Authentifizierung fehlgeschlagen', 'Kein Authorization-Code erhalten.')); + return; + } + + const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; + const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost'; + const redirectUri = `${proto}://${host}/api/game-library/gog/callback`; + + // Exchange code for tokens + const tokens = await exchangeGogCode(code, redirectUri); + + // Fetch user info + games + const [userInfo, games] = await Promise.all([ + fetchGogUserInfo(tokens.accessToken), + fetchGogGames(tokens.accessToken), + ]); + + const data = loadData(ctx); + if (!data.gogUsers) data.gogUsers = {}; + if (!data.profiles) data.profiles = {}; + + // Store GOG user + data.gogUsers[userInfo.userId] = { + gogUserId: userInfo.userId, + username: userInfo.username, + avatarUrl: userInfo.avatarUrl, + games, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenExpiresAt: tokens.expiresAt, + lastUpdated: new Date().toISOString(), + }; + + // Link to existing profile or create new one + let profileName = userInfo.username; + if (linkToProfileId && data.profiles[linkToProfileId]) { + // Link GOG to existing profile + data.profiles[linkToProfileId].gogUserId = userInfo.userId; + data.profiles[linkToProfileId].lastUpdated = new Date().toISOString(); + profileName = data.profiles[linkToProfileId].displayName; + } else { + // Check if GOG user already has a profile + const existingProfile = Object.values(data.profiles).find(p => p.gogUserId === userInfo.userId); + if (!existingProfile) { + const profileId = crypto.randomUUID(); + data.profiles[profileId] = { + id: profileId, + displayName: userInfo.username, + avatarUrl: userInfo.avatarUrl, + gogUserId: userInfo.userId, + lastUpdated: new Date().toISOString(), + }; + } + } + + saveData(ctx, data); + broadcastUpdate(data); + + console.log(`[GameLibrary] GOG verknuepft: ${profileName} (${userInfo.userId}) - ${games.length} Spiele`); + + res.send(`GOG verbunden

GOG verbunden!

${profileName}: ${games.length} Spiele geladen.

Du kannst dieses Fenster schliessen.

`); + } catch (err) { + console.error('[GameLibrary] GOG Callback error:', err); + res.status(500).send(errorPage('GOG-Fehler', 'Ein unerwarteter Fehler ist aufgetreten.')); + } + }); + + // ── GET /api/game-library/profile/:profileId/games ── + app.get('/api/game-library/profile/:profileId/games', (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 mergedGames: Array<{ + name: string; + platform: 'steam' | 'gog'; + appid?: number; + gogId?: number; + playtime_forever?: number; + img_icon_url?: string; + image?: string; + igdb?: IgdbGameInfo; + }> = []; + + // Add Steam games + if (profile.steamId && data.users[profile.steamId]) { + for (const g of data.users[profile.steamId].games) { + mergedGames.push({ + name: g.name, + platform: 'steam', + appid: g.appid, + playtime_forever: g.playtime_forever, + img_icon_url: g.img_icon_url, + igdb: g.igdb, + }); + } + } + + // Add GOG games (avoid duplicates by name) + if (profile.gogUserId && data.gogUsers[profile.gogUserId]) { + const steamNames = new Set(mergedGames.map(g => g.name.toLowerCase())); + for (const g of data.gogUsers[profile.gogUserId].games) { + if (!steamNames.has(g.title.toLowerCase())) { + mergedGames.push({ + name: g.title, + platform: 'gog', + gogId: g.gogId, + image: g.image, + igdb: g.igdb, + }); + } + } + } + + // Sort by playtime (Steam first since they have playtime), then by name + mergedGames.sort((a, b) => (b.playtime_forever || 0) - (a.playtime_forever || 0)); + + res.json({ + profile: { + id: profile.id, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + steamId: profile.steamId, + gogUserId: profile.gogUserId, + }, + games: mergedGames, + totalGames: mergedGames.length, + }); + }); + // ── DELETE /api/game-library/user/:steamId ── app.delete('/api/game-library/user/:steamId', (req, res) => { const { steamId } = req.params; diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx index 4372fa7..ea52312 100644 --- a/web/src/plugins/game-library/GameLibraryTab.tsx +++ b/web/src/plugins/game-library/GameLibraryTab.tsx @@ -5,14 +5,6 @@ import './game-library.css'; TYPES ══════════════════════════════════════════════════════════════════ */ -interface UserSummary { - steamId: string; - personaName: string; - avatarUrl: string; - gameCount: number; - lastUpdated: string; -} - interface IgdbData { igdbId: number; name: string; @@ -25,11 +17,26 @@ interface IgdbData { igdbUrl: string | null; } -interface SteamGame { - appid: number; +interface ProfileSummary { + id: string; + displayName: string; + avatarUrl: string; + platforms: { + steam: { steamId: string; personaName: string; gameCount: number } | null; + gog: { gogUserId: string; username: string; gameCount: number } | null; + }; + totalGames: number; + lastUpdated: string; +} + +interface MergedGame { name: string; - playtime_forever: number; - img_icon_url: string; + platform: 'steam' | 'gog'; + appid?: number; + gogId?: number; + playtime_forever?: number; + img_icon_url?: string; + image?: string; igdb?: IgdbData; } @@ -56,7 +63,8 @@ function gameIconUrl(appid: number, hash: string): string { return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`; } -function formatPlaytime(minutes: number): string { +function formatPlaytime(minutes: number | undefined): string { + if (minutes == null || minutes === 0) return '—'; if (minutes < 60) return `${minutes} Min`; const h = Math.floor(minutes / 60); const m = minutes % 60; @@ -83,11 +91,11 @@ function formatDate(iso: string): string { export default function GameLibraryTab({ data }: { data: any }) { // ── State ── - const [users, setUsers] = useState([]); + const [profiles, setProfiles] = 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 [selectedProfile, setSelectedProfile] = useState(null); + const [selectedProfiles, setSelectedProfiles] = useState>(new Set()); + const [userGames, setUserGames] = useState(null); const [commonGames, setCommonGames] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); @@ -103,20 +111,18 @@ export default function GameLibraryTab({ data }: { data: any }) { // ── SSE data sync ── useEffect(() => { - if (data?.users) setUsers(data.users); + if (data?.profiles) setProfiles(data.profiles); }, [data]); - // ── Refetch users ── - const fetchUsers = useCallback(async () => { + // ── Refetch profiles ── + const fetchProfiles = useCallback(async () => { try { - const resp = await fetch('/api/game-library/users'); + const resp = await fetch('/api/game-library/profiles'); if (resp.ok) { const d = await resp.json(); - setUsers(d.users || []); + setProfiles(d.profiles || []); } - } catch { - /* silent */ - } + } catch { /* silent */ } }, []); // ── Steam login ── @@ -125,45 +131,63 @@ export default function GameLibraryTab({ data }: { data: any }) { const interval = setInterval(() => { if (w && w.closed) { clearInterval(interval); - setTimeout(fetchUsers, 1000); + setTimeout(fetchProfiles, 1000); } }, 500); - }, [fetchUsers]); + }, [fetchProfiles]); + + // ── GOG login ── + const connectGog = useCallback(() => { + // If viewing a profile, pass linkTo param so GOG gets linked to it + const linkParam = selectedProfile ? `?linkTo=${selectedProfile}` : ''; + const w = window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=600'); + const interval = setInterval(() => { + if (w && w.closed) { + clearInterval(interval); + setTimeout(fetchProfiles, 1500); + } + }, 500); + }, [fetchProfiles, selectedProfile]); // ── Refetch on window focus (after login redirect) ── useEffect(() => { - const onFocus = () => fetchUsers(); + const onFocus = () => fetchProfiles(); window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); - }, [fetchUsers]); + }, [fetchProfiles]); - // ── View user library ── - const viewUser = useCallback(async (steamId: string) => { + // ── View profile library ── + const viewProfile = useCallback(async (profileId: string) => { setMode('user'); - setSelectedUser(steamId); + setSelectedProfile(profileId); setUserGames(null); setFilterQuery(''); setLoading(true); try { - const resp = await fetch(`/api/game-library/user/${steamId}`); + const resp = await fetch(`/api/game-library/profile/${profileId}/games`); if (resp.ok) { const d = await resp.json(); - const games: SteamGame[] = d.games || d; + const games: MergedGame[] = d.games || d; setUserGames(games); // Auto-enrich with IGDB if many games lack data - const unenriched = games.filter(g => !g.igdb).length; - if (unenriched > 0) { - setEnriching(steamId); - fetch(`/api/game-library/igdb/enrich/${steamId}`) - .then(r => r.ok ? r.json() : null) - .then(() => fetch(`/api/game-library/user/${steamId}`)) - .then(r => r.ok ? r.json() : null) - .then(d2 => { - if (d2) setUserGames(d2.games || d2); - }) - .catch(() => {}) - .finally(() => setEnriching(null)); + // Find steamId from profile to use for enrichment + const profile = profiles.find(p => p.id === profileId); + const steamId = profile?.platforms?.steam?.steamId; + if (steamId) { + const unenriched = games.filter(g => !g.igdb).length; + if (unenriched > 0) { + setEnriching(profileId); + fetch(`/api/game-library/igdb/enrich/${steamId}`) + .then(r => r.ok ? r.json() : null) + .then(() => fetch(`/api/game-library/profile/${profileId}/games`)) + .then(r => r.ok ? r.json() : null) + .then(d2 => { + if (d2) setUserGames(d2.games || d2); + }) + .catch(() => {}) + .finally(() => setEnriching(null)); + } } } } catch { @@ -171,55 +195,62 @@ export default function GameLibraryTab({ data }: { data: any }) { } finally { setLoading(false); } - }, []); + }, [profiles]); - // ── Refresh single user ── - const refreshUser = useCallback(async (steamId: string, e?: React.MouseEvent) => { + // ── Refresh single profile ── + const refreshProfile = useCallback(async (profileId: string, e?: React.MouseEvent) => { if (e) e.stopPropagation(); + const profile = profiles.find(p => p.id === profileId); + const steamId = profile?.platforms?.steam?.steamId; try { - await fetch(`/api/game-library/user/${steamId}?refresh=true`); - await fetchUsers(); - if (mode === 'user' && selectedUser === steamId) { - viewUser(steamId); + if (steamId) { + await fetch(`/api/game-library/user/${steamId}?refresh=true`); + } + await fetchProfiles(); + if (mode === 'user' && selectedProfile === profileId) { + viewProfile(profileId); } } catch { /* silent */ } - }, [fetchUsers, mode, selectedUser, viewUser]); + }, [fetchProfiles, mode, selectedProfile, viewProfile, profiles]); - // ── Enrich user library with IGDB data ── - const enrichUser = useCallback(async (steamId: string) => { - setEnriching(steamId); + // ── Enrich profile library with IGDB data ── + const enrichProfile = useCallback(async (profileId: string) => { + const profile = profiles.find(p => p.id === profileId); + const steamId = profile?.platforms?.steam?.steamId; + if (!steamId) return; + setEnriching(profileId); try { const resp = await fetch(`/api/game-library/igdb/enrich/${steamId}`); if (resp.ok) { - // Reload user's game data to get IGDB info - if (mode === 'user' && selectedUser === steamId) { - viewUser(steamId); + // Reload profile's game data to get IGDB info + if (mode === 'user' && selectedProfile === profileId) { + viewProfile(profileId); } } } catch { /* silent */ } finally { setEnriching(null); } - }, [mode, selectedUser, viewUser]); + }, [mode, selectedProfile, viewProfile, profiles]); - // ── Toggle user selection for common games ── - const toggleCommonUser = useCallback((steamId: string) => { - setSelectedUsers(prev => { + // ── Toggle profile selection for common games ── + const toggleCommonProfile = useCallback((profileId: string) => { + setSelectedProfiles(prev => { const next = new Set(prev); - if (next.has(steamId)) next.delete(steamId); - else next.add(steamId); + if (next.has(profileId)) next.delete(profileId); + else next.add(profileId); return next; }); }, []); // ── Find common games ── const findCommonGames = useCallback(async () => { - if (selectedUsers.size < 2) return; + if (selectedProfiles.size < 2) return; setMode('common'); setCommonGames(null); setLoading(true); try { - const ids = Array.from(selectedUsers).join(','); + const ids = Array.from(selectedProfiles).join(','); const resp = await fetch(`/api/game-library/common-games?users=${ids}`); if (resp.ok) { const d = await resp.json(); @@ -230,7 +261,7 @@ export default function GameLibraryTab({ data }: { data: any }) { } finally { setLoading(false); } - }, [selectedUsers]); + }, [selectedProfiles]); // ── Search (debounced) ── const handleSearch = useCallback((value: string) => { @@ -266,7 +297,7 @@ export default function GameLibraryTab({ data }: { data: any }) { // ── Back to overview ── const goBack = useCallback(() => { setMode('overview'); - setSelectedUser(null); + setSelectedProfile(null); setUserGames(null); setCommonGames(null); setFilterQuery(''); @@ -274,10 +305,10 @@ export default function GameLibraryTab({ data }: { data: any }) { setSortBy('playtime'); }, []); - // ── Resolve user by steamId ── - const getUser = useCallback( - (steamId: string) => users.find(u => u.steamId === steamId), - [users], + // ── Resolve profile by id ── + const getProfile = useCallback( + (profileId: string) => profiles.find(p => p.id === profileId), + [profiles], ); // ── Sort helper ── @@ -343,89 +374,103 @@ export default function GameLibraryTab({ data }: { data: any }) { return (
- {/* ── Top bar ── */} -
- -
- {users.map(u => ( + +
+ + {/* ── Profile Chips ── */} + {profiles.length > 0 && ( +
+ {profiles.map(p => (
viewUser(u.steamId)} + key={p.id} + className={`gl-profile-chip${selectedProfile === p.id ? ' selected' : ''}`} + onClick={() => viewProfile(p.id)} > - {u.personaName} - {u.personaName} - ({u.gameCount}) - + {p.displayName} +
+ {p.displayName} + + {p.platforms.steam && S} + {p.platforms.gog && G} + +
+ ({p.totalGames})
))}
-
+ )} {/* ── Overview mode ── */} {mode === 'overview' && ( <> - {users.length === 0 ? ( + {profiles.length === 0 ? (
🎮
-

Keine Steam-Konten verbunden

+

Keine Konten verbunden

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

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

Verbundene Spieler

- {users.map(u => ( -
viewUser(u.steamId)}> - {u.personaName} - {u.personaName} - {u.gameCount} Spiele + {profiles.map(p => ( +
viewProfile(p.id)}> + {p.displayName} + {p.displayName} +
+ {p.platforms.steam && S} + {p.platforms.gog && G} +
+ {p.totalGames} Spiele - Aktualisiert: {formatDate(u.lastUpdated)} + Aktualisiert: {formatDate(p.lastUpdated)}
))}
{/* Common games finder */} - {users.length >= 2 && ( + {profiles.length >= 2 && (

Gemeinsame Spiele finden

- {users.map(u => ( + {profiles.map(p => ( ))}
- {user && ( + {profile && ( <> - {user.personaName} + {profile.displayName}
- {user.personaName} - {user.gameCount} Spiele + {profile.displayName} + {profile.totalGames} Spiele
- Aktualisiert: {formatDate(user.lastUpdated)} + {profile.platforms.steam && ( + + Steam ✓ + + )} + {profile.platforms.gog ? ( + + GOG ✓ + + ) : ( + + )}
- + {profile.platforms.steam && ( + + )} )}
@@ -577,14 +637,20 @@ export default function GameLibraryTab({ data }: { data: any }) {

Keine Spiele gefunden.

) : (
- {filteredGames.map(g => ( -
+ {filteredGames.map((g, idx) => ( +
+ {/* Platform indicator */} + + {g.platform === 'gog' ? 'G' : 'S'} + {/* Cover/Icon */}
{g.igdb?.coverUrl ? ( - ) : g.img_icon_url ? ( + ) : g.img_icon_url && g.appid ? ( + ) : g.image ? ( + ) : (
)} @@ -621,10 +687,10 @@ export default function GameLibraryTab({ data }: { data: any }) { {/* ── 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(', '); + const selected = Array.from(selectedProfiles) + .map(id => getProfile(id)) + .filter(Boolean) as ProfileSummary[]; + const names = selected.map(p => p.displayName).join(', '); return ( <>
@@ -632,8 +698,8 @@ export default function GameLibraryTab({ data }: { data: any }) { ← Zurueck
- {selected.map(u => ( - {u.personaName} + {selected.map(p => ( + {p.displayName} ))}
diff --git a/web/src/plugins/game-library/game-library.css b/web/src/plugins/game-library/game-library.css index bc07707..896c6bc 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -9,20 +9,16 @@ margin: 0 auto; } -/* ── Top bar ── */ +/* ── Login Bar ── */ -.gl-topbar { +.gl-login-bar { display: flex; - align-items: center; - gap: 12px; - margin-bottom: 24px; - flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; } .gl-connect-btn { - background: #1b2838; color: #c7d5e0; - border: 1px solid #2a475e; padding: 10px 20px; border-radius: var(--radius); cursor: pointer; @@ -31,71 +27,126 @@ white-space: nowrap; } -.gl-connect-btn:hover { +.gl-steam-btn { + background: #1b2838; + border: 1px solid #2a475e; +} + +.gl-steam-btn:hover { background: #2a475e; color: #fff; } -/* ── User chips (top bar) ── */ +.gl-gog-btn { + background: #2c1a4e; + border: 1px solid #4a2d7a; + color: #c7b3e8; +} -.gl-user-chips { +.gl-gog-btn:hover { + background: #3d2566; + color: #fff; +} + +/* ── Profile Chips ── */ + +.gl-profile-chips { display: flex; gap: 8px; flex-wrap: wrap; - flex: 1; + margin-bottom: 16px; } -.gl-user-chip { +.gl-profile-chip { display: flex; align-items: center; - gap: 6px; - background: var(--bg-secondary); - padding: 4px 10px 4px 4px; + gap: 8px; + padding: 6px 12px; border-radius: 20px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); cursor: pointer; - transition: all var(--transition); - border: 1px solid transparent; + transition: all 0.2s; } -.gl-user-chip:hover { - border-color: var(--accent); +.gl-profile-chip.selected { + border-color: #e67e22; + background: rgba(230,126,34,0.1); } -.gl-user-chip.selected { - border-color: var(--accent); - background: rgba(230, 126, 34, 0.12); +.gl-profile-chip:hover { + background: rgba(255,255,255,0.1); } -.gl-user-chip-avatar { +.gl-profile-chip-avatar { width: 28px; height: 28px; border-radius: 50%; } -.gl-user-chip-name { - font-size: 13px; - color: var(--text-normal); +.gl-profile-chip-info { + display: flex; + flex-direction: column; + gap: 2px; } -.gl-user-chip-count { +.gl-profile-chip-name { + font-size: 13px; + font-weight: 600; + color: #e0e0e0; +} + +.gl-profile-chip-platforms { + display: flex; + gap: 4px; +} + +.gl-profile-chip-count { font-size: 11px; - color: var(--text-faint); + color: #667; } -.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); +/* ── Platform Badges ── */ + +.gl-platform-badge { + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + text-transform: uppercase; } -.gl-user-chip-refresh:hover { - color: var(--accent); +.gl-platform-badge.steam { + background: rgba(27,40,56,0.8); + color: #66c0f4; + border: 1px solid #2a475e; +} + +.gl-platform-badge.gog { + background: rgba(44,26,78,0.8); + color: #b388ff; + border: 1px solid #4a2d7a; +} + +/* ── Game Platform Icon ── */ + +.gl-game-platform-icon { + font-size: 9px; + font-weight: 700; + padding: 1px 4px; + border-radius: 3px; + margin-right: 6px; + flex-shrink: 0; +} + +.gl-game-platform-icon.steam { + background: rgba(27,40,56,0.6); + color: #66c0f4; +} + +.gl-game-platform-icon.gog { + background: rgba(44,26,78,0.6); + color: #b388ff; } /* ── User cards grid (overview) ── */ @@ -147,6 +198,14 @@ color: var(--text-faint); } +/* ── Profile Cards ── */ + +.gl-profile-card-platforms { + display: flex; + gap: 6px; + margin-top: 4px; +} + /* ── Common games finder ── */ .gl-common-finder { @@ -338,6 +397,10 @@ .gl-detail-sub { font-size: 13px; color: var(--text-faint); + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; } .gl-refresh-btn { @@ -354,6 +417,24 @@ color: var(--accent); } +/* ── Link GOG Button ── */ + +.gl-link-gog-btn { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; + border: 1px solid rgba(168, 85, 247, 0.3); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.2s; +} + +.gl-link-gog-btn:hover { + background: rgba(168, 85, 247, 0.25); +} + /* ── Loading ── */ .gl-loading { @@ -652,8 +733,7 @@ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } - .gl-topbar { + .gl-login-bar { flex-direction: column; - align-items: stretch; } }