Game Library: Multi-Platform Profile System + GOG Integration

- Neues Profile-System: User können Steam und GOG verknüpfen
- GOG OAuth2 Login-Flow (auth.gog.com)
- GOG API Service (gog.ts): Token-Management, Spieleliste, User-Info
- Server: Profile-Datenmodell, Migration bestehender Steam-Users
- Frontend: Login-Bar (Steam + GOG), Profile-Chips mit Platform-Badges
- Cross-Platform Game-Merging mit Deduplizierung
- Profile-Detail mit "GOG verknüpfen" Option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 12:34:43 +01:00
parent 89e655482b
commit ee5c29dd7b
4 changed files with 859 additions and 189 deletions

View file

@ -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<GogTokens> {
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<GogTokens> {
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<GogUserInfo> {
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<GogGame[]> {
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;
}

View file

@ -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<string, SteamUser>; // keyed by steamId
users: Record<string, SteamUser>; // keyed by steamId
gogUsers: Record<string, GogUserData>; // keyed by gogUserId
profiles: Record<string, UserProfile>; // 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(`<!DOCTYPE html><html><head><title>GOG verbunden</title><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#a855f7}</style></head><body><div><h2>GOG verbunden!</h2><p>${profileName}: ${games.length} Spiele geladen.</p><p>Du kannst dieses Fenster schliessen.</p><script>setTimeout(()=>window.close(),3000)</script></div></body></html>`);
} 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;

View file

@ -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<UserSummary[]>([]);
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [userGames, setUserGames] = useState<SteamGame[] | null>(null);
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [userGames, setUserGames] = useState<MergedGame[] | null>(null);
const [commonGames, setCommonGames] = useState<CommonGame[] | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(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 (
<div className="gl-container">
{/* ── Top bar ── */}
<div className="gl-topbar">
<button className="gl-connect-btn" onClick={connectSteam}>
Mit Steam verbinden
{/* ── Login Bar ── */}
<div className="gl-login-bar">
<button className="gl-connect-btn gl-steam-btn" onClick={connectSteam}>
🎮 Steam verbinden
</button>
<div className="gl-user-chips">
{users.map(u => (
<button className="gl-connect-btn gl-gog-btn" onClick={connectGog}>
🟣 GOG verbinden
</button>
</div>
{/* ── Profile Chips ── */}
{profiles.length > 0 && (
<div className="gl-profile-chips">
{profiles.map(p => (
<div
key={u.steamId}
className={`gl-user-chip${selectedUser === u.steamId ? ' selected' : ''}`}
onClick={() => viewUser(u.steamId)}
key={p.id}
className={`gl-profile-chip${selectedProfile === p.id ? ' selected' : ''}`}
onClick={() => viewProfile(p.id)}
>
<img className="gl-user-chip-avatar" src={u.avatarUrl} alt={u.personaName} />
<span className="gl-user-chip-name">{u.personaName}</span>
<span className="gl-user-chip-count">({u.gameCount})</span>
<button
className="gl-user-chip-refresh"
onClick={e => refreshUser(u.steamId, e)}
title="Aktualisieren"
>
&#x21bb;
</button>
<img className="gl-profile-chip-avatar" src={p.avatarUrl} alt={p.displayName} />
<div className="gl-profile-chip-info">
<span className="gl-profile-chip-name">{p.displayName}</span>
<span className="gl-profile-chip-platforms">
{p.platforms.steam && <span className="gl-platform-badge steam" title={`Steam: ${p.platforms.steam.gameCount} Spiele`}>S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog" title={`GOG: ${p.platforms.gog.gameCount} Spiele`}>G</span>}
</span>
</div>
<span className="gl-profile-chip-count">({p.totalGames})</span>
</div>
))}
</div>
</div>
)}
{/* ── Overview mode ── */}
{mode === 'overview' && (
<>
{users.length === 0 ? (
{profiles.length === 0 ? (
<div className="gl-empty">
<div className="gl-empty-icon">&#x1F3AE;</div>
<h3>Keine Steam-Konten verbunden</h3>
<h3>Keine Konten verbunden</h3>
<p>
Klicke oben auf &ldquo;Mit Steam verbinden&rdquo;, um deine Spielebibliothek
hinzuzufuegen.
Klicke oben auf &ldquo;Steam verbinden&rdquo; oder &ldquo;GOG verbinden&rdquo;, um deine
Spielebibliothek hinzuzufuegen.
</p>
</div>
) : (
<>
{/* User cards */}
{/* Profile cards */}
<p className="gl-section-title">Verbundene Spieler</p>
<div className="gl-users-grid">
{users.map(u => (
<div key={u.steamId} className="gl-user-card" onClick={() => viewUser(u.steamId)}>
<img className="gl-user-card-avatar" src={u.avatarUrl} alt={u.personaName} />
<span className="gl-user-card-name">{u.personaName}</span>
<span className="gl-user-card-games">{u.gameCount} Spiele</span>
{profiles.map(p => (
<div key={p.id} className="gl-user-card" onClick={() => viewProfile(p.id)}>
<img className="gl-user-card-avatar" src={p.avatarUrl} alt={p.displayName} />
<span className="gl-user-card-name">{p.displayName}</span>
<div className="gl-profile-card-platforms">
{p.platforms.steam && <span className="gl-platform-badge steam" title="Steam">S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog" title="GOG">G</span>}
</div>
<span className="gl-user-card-games">{p.totalGames} Spiele</span>
<span className="gl-user-card-updated">
Aktualisiert: {formatDate(u.lastUpdated)}
Aktualisiert: {formatDate(p.lastUpdated)}
</span>
</div>
))}
</div>
{/* Common games finder */}
{users.length >= 2 && (
{profiles.length >= 2 && (
<div className="gl-common-finder">
<h3>Gemeinsame Spiele finden</h3>
<div className="gl-common-users">
{users.map(u => (
{profiles.map(p => (
<label
key={u.steamId}
className={`gl-common-check${selectedUsers.has(u.steamId) ? ' checked' : ''}`}
key={p.id}
className={`gl-common-check${selectedProfiles.has(p.id) ? ' checked' : ''}`}
>
<input
type="checkbox"
checked={selectedUsers.has(u.steamId)}
onChange={() => toggleCommonUser(u.steamId)}
checked={selectedProfiles.has(p.id)}
onChange={() => toggleCommonProfile(p.id)}
/>
<img
className="gl-common-check-avatar"
src={u.avatarUrl}
alt={u.personaName}
src={p.avatarUrl}
alt={p.displayName}
/>
{u.personaName}
{p.displayName}
<span className="gl-profile-chip-platforms" style={{ marginLeft: 4 }}>
{p.platforms.steam && <span className="gl-platform-badge steam">S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog">G</span>}
</span>
</label>
))}
</div>
<button
className="gl-common-find-btn"
disabled={selectedUsers.size < 2}
disabled={selectedProfiles.size < 2}
onClick={findCommonGames}
>
Finden
@ -465,12 +510,12 @@ export default function GameLibraryTab({ data }: { data: any }) {
<span className="gl-game-name">{g.name}</span>
<div className="gl-game-owners">
{g.owners.map(o => {
const u = getUser(o.steamId);
return u ? (
const profile = profiles.find(p => p.platforms.steam?.steamId === o.steamId);
return profile ? (
<img
key={o.steamId}
className="gl-game-owner-avatar"
src={u.avatarUrl}
src={profile.avatarUrl}
alt={o.personaName}
title={o.personaName}
/>
@ -491,42 +536,57 @@ export default function GameLibraryTab({ data }: { data: any }) {
</>
)}
{/* ── User mode ── */}
{/* ── User mode (profile detail) ── */}
{mode === 'user' && (() => {
const user = selectedUser ? getUser(selectedUser) : null;
const profile = selectedProfile ? getProfile(selectedProfile) : null;
return (
<>
<div className="gl-detail-header">
<button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck
</button>
{user && (
{profile && (
<>
<img className="gl-detail-avatar" src={user.avatarUrl} alt={user.personaName} />
<img className="gl-detail-avatar" src={profile.avatarUrl} alt={profile.displayName} />
<div className="gl-detail-info">
<div className="gl-detail-name">
{user.personaName}
<span className="gl-game-count">{user.gameCount} Spiele</span>
{profile.displayName}
<span className="gl-game-count">{profile.totalGames} Spiele</span>
</div>
<div className="gl-detail-sub">
Aktualisiert: {formatDate(user.lastUpdated)}
{profile.platforms.steam && (
<span className="gl-platform-badge steam" style={{ marginRight: 4 }}>
Steam
</span>
)}
{profile.platforms.gog ? (
<span className="gl-platform-badge gog">
GOG
</span>
) : (
<button className="gl-link-gog-btn" onClick={connectGog}>
🟣 GOG verknuepfen
</button>
)}
</div>
</div>
<button
className="gl-refresh-btn"
onClick={() => refreshUser(user.steamId)}
onClick={() => refreshProfile(profile.id)}
title="Aktualisieren"
>
&#x21bb;
</button>
<button
className={`gl-enrich-btn ${enriching === selectedUser ? 'enriching' : ''}`}
onClick={() => enrichUser(selectedUser!)}
disabled={enriching === selectedUser}
title={enriching === selectedUser ? 'IGDB-Daten werden geladen...' : 'Mit IGDB-Daten anreichern (erneut)'}
>
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
</button>
{profile.platforms.steam && (
<button
className={`gl-enrich-btn ${enriching === selectedProfile ? 'enriching' : ''}`}
onClick={() => enrichProfile(selectedProfile!)}
disabled={enriching === selectedProfile}
title={enriching === selectedProfile ? 'IGDB-Daten werden geladen...' : 'Mit IGDB-Daten anreichern (erneut)'}
>
{enriching === selectedProfile ? '\u23F3' : '\uD83C\uDF10'} IGDB
</button>
)}
</>
)}
</div>
@ -577,14 +637,20 @@ export default function GameLibraryTab({ data }: { data: any }) {
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
) : (
<div className="gl-game-list">
{filteredGames.map(g => (
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{filteredGames.map((g, idx) => (
<div key={g.appid ?? g.gogId ?? idx} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{/* Platform indicator */}
<span className={`gl-game-platform-icon ${g.platform || 'steam'}`}>
{g.platform === 'gog' ? 'G' : 'S'}
</span>
{/* Cover/Icon */}
<div className="gl-game-visual">
{g.igdb?.coverUrl ? (
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
) : g.img_icon_url ? (
) : g.img_icon_url && g.appid ? (
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
) : g.image ? (
<img className="gl-game-icon" src={g.image} alt="" />
) : (
<div className="gl-game-icon" />
)}
@ -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 (
<>
<div className="gl-detail-header">
@ -632,8 +698,8 @@ export default function GameLibraryTab({ data }: { data: any }) {
&#x2190; Zurueck
</button>
<div className="gl-detail-avatars">
{selected.map(u => (
<img key={u.steamId} src={u.avatarUrl} alt={u.personaName} />
{selected.map(p => (
<img key={p.id} src={p.avatarUrl} alt={p.displayName} />
))}
</div>
<div className="gl-detail-info">

View file

@ -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;
}
}