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:
parent
89e655482b
commit
ee5c29dd7b
4 changed files with 859 additions and 189 deletions
288
server/src/plugins/game-library/gog.ts
Normal file
288
server/src/plugins/game-library/gog.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 || []);
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
setProfiles(d.profiles || []);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
// ── Steam login ──
|
||||
|
|
@ -125,39 +131,56 @@ 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
|
||||
// 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(steamId);
|
||||
setEnriching(profileId);
|
||||
fetch(`/api/game-library/igdb/enrich/${steamId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(() => fetch(`/api/game-library/user/${steamId}`))
|
||||
.then(() => fetch(`/api/game-library/profile/${profileId}/games`))
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d2 => {
|
||||
if (d2) setUserGames(d2.games || d2);
|
||||
|
|
@ -166,60 +189,68 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
.finally(() => setEnriching(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
} 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 {
|
||||
if (steamId) {
|
||||
await fetch(`/api/game-library/user/${steamId}?refresh=true`);
|
||||
await fetchUsers();
|
||||
if (mode === 'user' && selectedUser === steamId) {
|
||||
viewUser(steamId);
|
||||
}
|
||||
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"
|
||||
>
|
||||
↻
|
||||
</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">🎮</div>
|
||||
<h3>Keine Steam-Konten verbunden</h3>
|
||||
<h3>Keine Konten verbunden</h3>
|
||||
<p>
|
||||
Klicke oben auf “Mit Steam verbinden”, um deine Spielebibliothek
|
||||
hinzuzufuegen.
|
||||
Klicke oben auf “Steam verbinden” oder “GOG verbinden”, 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}>
|
||||
← 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"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
{profile.platforms.steam && (
|
||||
<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)'}
|
||||
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 === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
|
||||
{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 }) {
|
|||
← 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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue