IGDB-Integration für Game Library + Electron Update-Button im Version-Modal
- Neues IGDB-Service-Modul (igdb.ts): Token-Management, Rate-Limiting, Game-Lookup per Steam-AppID/Name, Batch-Enrichment mit In-Memory-Cache - Server: 2 neue Routes (/igdb/enrich/:steamId, /igdb/game/:appid), Auto-Enrichment bei Steam-Login und Refresh - Frontend: IGDB-Cover, Genre-Tags, Plattform-Badges, farbcodiertes Rating, IGDB-Anreichern-Button - Version-Modal: Update-Button für Electron-App (checkForUpdates/installUpdate), Desktop-App-Version anzeigen - CSS: Styles für IGDB-Elemente und Update-UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aec1142bff
commit
b404c20eca
6 changed files with 785 additions and 25 deletions
314
server/src/plugins/game-library/igdb.ts
Normal file
314
server/src/plugins/game-library/igdb.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// IGDB (Internet Game Database) API service
|
||||
// Uses Twitch OAuth for authentication. All requests go through a simple
|
||||
// throttle so we never exceed the 4 req/s rate-limit.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TWITCH_CLIENT_ID = 'n6u8unhmwvhzsrvw2d2nb2a3qxapsl';
|
||||
const TWITCH_CLIENT_SECRET = 'h6f6g2r6yyxkfg2xsob0jt8p994s7v';
|
||||
const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/token';
|
||||
const IGDB_API_URL = 'https://api.igdb.com/v4';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IgdbGameInfo {
|
||||
igdbId: number;
|
||||
name: string;
|
||||
coverUrl: string | null;
|
||||
genres: string[];
|
||||
platforms: string[];
|
||||
rating: number | null;
|
||||
firstReleaseDate: string | null;
|
||||
summary: string | null;
|
||||
igdbUrl: string | null;
|
||||
}
|
||||
|
||||
// ── Token cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface TokenCache {
|
||||
accessToken: string;
|
||||
expiresAt: number; // unix-ms
|
||||
}
|
||||
|
||||
let tokenCache: TokenCache | null = null;
|
||||
|
||||
/**
|
||||
* Obtain (and cache) a Twitch OAuth bearer token for the IGDB API.
|
||||
* Tokens typically last ~60 days. We refresh automatically when the cached
|
||||
* token is expired (or about to expire within 60 seconds).
|
||||
*/
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
|
||||
return tokenCache.accessToken;
|
||||
}
|
||||
|
||||
console.log('[IGDB] Fetching new Twitch OAuth token...');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: TWITCH_CLIENT_ID,
|
||||
client_secret: TWITCH_CLIENT_SECRET,
|
||||
grant_type: 'client_credentials',
|
||||
});
|
||||
|
||||
const res = await fetch(TWITCH_AUTH_URL, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`[IGDB] Token request failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
tokenCache = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[IGDB] Token acquired, expires in ${Math.round(data.expires_in / 86400)} days`,
|
||||
);
|
||||
|
||||
return tokenCache.accessToken;
|
||||
}
|
||||
|
||||
// ── Rate-limit throttle (max 4 req/s → min 250 ms between requests) ─────────
|
||||
|
||||
const MIN_REQUEST_INTERVAL_MS = 250;
|
||||
let lastRequestTime = 0;
|
||||
|
||||
async function throttle(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRequestTime;
|
||||
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed),
|
||||
);
|
||||
}
|
||||
lastRequestTime = Date.now();
|
||||
}
|
||||
|
||||
// ── Generic IGDB query runner ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send an IGDB Apicalypse query to the given endpoint.
|
||||
*
|
||||
* @param endpoint e.g. "games", "covers"
|
||||
* @param body IGDB query-language string
|
||||
* @returns parsed JSON array
|
||||
*/
|
||||
export async function igdbQuery(
|
||||
endpoint: string,
|
||||
body: string,
|
||||
): Promise<any[]> {
|
||||
const token = await getAccessToken();
|
||||
await throttle();
|
||||
|
||||
const url = `${IGDB_API_URL}/${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Client-ID': TWITCH_CLIENT_ID,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[IGDB] Query to ${endpoint} failed (${res.status}): ${text}`);
|
||||
throw new Error(`[IGDB] Query failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as any[];
|
||||
}
|
||||
|
||||
// ── Cover URL helper ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert an IGDB cover URL (or hash) into a full HTTPS image URL.
|
||||
*
|
||||
* IGDB returns cover.url like `//images.igdb.com/igdb/image/upload/t_thumb/co1234.jpg`
|
||||
* This helper swaps in the requested size template and ensures HTTPS.
|
||||
*
|
||||
* @param hash The cover hash or raw URL returned by the API
|
||||
* @param size Image size template – defaults to `t_cover_big` (264x374)
|
||||
*/
|
||||
export function igdbCoverUrl(
|
||||
hash: string,
|
||||
size: string = 't_cover_big',
|
||||
): string {
|
||||
// If the caller passed a full IGDB url, extract the hash portion
|
||||
// e.g. "//images.igdb.com/igdb/image/upload/t_thumb/co1234.jpg" → "co1234"
|
||||
let imageHash = hash;
|
||||
|
||||
const match = hash.match(/\/t_\w+\/(.+?)(?:\.\w+)?$/);
|
||||
if (match) {
|
||||
imageHash = match[1];
|
||||
}
|
||||
|
||||
return `https://images.igdb.com/igdb/image/upload/${size}/${imageHash}.jpg`;
|
||||
}
|
||||
|
||||
// ── Raw response → IgdbGameInfo mapper ───────────────────────────────────────
|
||||
|
||||
function mapToGameInfo(raw: any): IgdbGameInfo {
|
||||
let coverUrl: string | null = null;
|
||||
if (raw.cover?.url) {
|
||||
coverUrl = igdbCoverUrl(raw.cover.url);
|
||||
}
|
||||
|
||||
let firstReleaseDate: string | null = null;
|
||||
if (typeof raw.first_release_date === 'number') {
|
||||
firstReleaseDate = new Date(raw.first_release_date * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
igdbId: raw.id,
|
||||
name: raw.name ?? 'Unknown',
|
||||
coverUrl,
|
||||
genres: Array.isArray(raw.genres)
|
||||
? raw.genres.map((g: any) => g.name).filter(Boolean)
|
||||
: [],
|
||||
platforms: Array.isArray(raw.platforms)
|
||||
? raw.platforms.map((p: any) => p.name).filter(Boolean)
|
||||
: [],
|
||||
rating: typeof raw.rating === 'number' ? Math.round(raw.rating) : null,
|
||||
firstReleaseDate,
|
||||
summary: raw.summary ?? null,
|
||||
igdbUrl: raw.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Game lookup functions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find a game by its Steam app-id using IGDB external-game references.
|
||||
* Category 1 = Steam in IGDB's external-game categories.
|
||||
*/
|
||||
export async function lookupByAppId(
|
||||
steamAppId: number,
|
||||
): Promise<IgdbGameInfo | null> {
|
||||
const body = [
|
||||
'fields name,cover.url,genres.name,platforms.name,rating,',
|
||||
'first_release_date,summary,url,external_games.uid,external_games.category;',
|
||||
`where external_games.category = 1 & external_games.uid = "${steamAppId}";`,
|
||||
'limit 1;',
|
||||
].join(' ');
|
||||
|
||||
const results = await igdbQuery('games', body);
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return mapToGameInfo(results[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: search for a game by name.
|
||||
*/
|
||||
export async function lookupByName(
|
||||
name: string,
|
||||
): Promise<IgdbGameInfo | null> {
|
||||
const safeName = name.replace(/"/g, '\\"');
|
||||
const body = [
|
||||
`search "${safeName}";`,
|
||||
'fields name,cover.url,genres.name,platforms.name,rating,',
|
||||
'first_release_date,summary,url;',
|
||||
'limit 1;',
|
||||
].join(' ');
|
||||
|
||||
const results = await igdbQuery('games', body);
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return mapToGameInfo(results[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to look up a game by Steam app-id first; fall back to name search.
|
||||
*/
|
||||
export async function lookupGame(
|
||||
steamAppId: number,
|
||||
gameName: string,
|
||||
): Promise<IgdbGameInfo | null> {
|
||||
const byId = await lookupByAppId(steamAppId);
|
||||
if (byId) return byId;
|
||||
|
||||
console.log(
|
||||
`[IGDB] No result for Steam appid ${steamAppId}, falling back to name search: "${gameName}"`,
|
||||
);
|
||||
return lookupByName(gameName);
|
||||
}
|
||||
|
||||
// ── Batch enrichment ─────────────────────────────────────────────────────────
|
||||
|
||||
/** In-memory cache keyed by Steam appid. Persists for the server lifetime. */
|
||||
const enrichmentCache = new Map<number, IgdbGameInfo>();
|
||||
|
||||
/**
|
||||
* Enrich a list of Steam games with IGDB metadata.
|
||||
*
|
||||
* - Processes in batches of 4 (respecting the 4 req/s rate limit).
|
||||
* - 300 ms pause between batches.
|
||||
* - Uses an in-memory cache so repeated calls for the same appid are free.
|
||||
*
|
||||
* @returns Map keyed by Steam appid → IgdbGameInfo (only matched games)
|
||||
*/
|
||||
export async function enrichGames(
|
||||
games: Array<{ appid: number; name: string }>,
|
||||
): Promise<Map<number, IgdbGameInfo>> {
|
||||
const result = new Map<number, IgdbGameInfo>();
|
||||
const toFetch: Array<{ appid: number; name: string }> = [];
|
||||
|
||||
// Check cache first
|
||||
for (const game of games) {
|
||||
const cached = enrichmentCache.get(game.appid);
|
||||
if (cached) {
|
||||
console.log(`[IGDB] Cache hit for appid ${game.appid} ("${cached.name}")`);
|
||||
result.set(game.appid, cached);
|
||||
} else {
|
||||
toFetch.push(game);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length === 0) return result;
|
||||
|
||||
const BATCH_SIZE = 4;
|
||||
for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
|
||||
if (i > 0) {
|
||||
// Pause between batches
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
const batch = toFetch.slice(i, i + BATCH_SIZE);
|
||||
const promises = batch.map(async (game) => {
|
||||
try {
|
||||
const info = await lookupGame(game.appid, game.name);
|
||||
if (info) {
|
||||
enrichmentCache.set(game.appid, info);
|
||||
result.set(game.appid, info);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[IGDB] Error enriching appid ${game.appid} ("${game.name}"):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[IGDB] Enrichment complete: ${result.size}/${games.length} games matched`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ 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 { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ interface SteamGame {
|
|||
name: string;
|
||||
playtime_forever: number; // minutes
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbGameInfo;
|
||||
}
|
||||
|
||||
interface SteamUser {
|
||||
|
|
@ -185,6 +187,21 @@ const gameLibraryPlugin: Plugin = {
|
|||
saveData(ctx, data);
|
||||
broadcastUpdate(data);
|
||||
|
||||
// Fire-and-forget IGDB enrichment
|
||||
enrichGames(games.map(g => ({ appid: g.appid, name: g.name }))).then(igdbMap => {
|
||||
const data2 = loadData(ctx);
|
||||
const user2 = data2.users[steamId];
|
||||
if (user2) {
|
||||
let count = 0;
|
||||
for (const game of user2.games) {
|
||||
const info = igdbMap.get(game.appid);
|
||||
if (info) { game.igdb = info; count++; }
|
||||
}
|
||||
saveData(ctx, data2);
|
||||
console.log(`[GameLibrary] IGDB auto-enrichment: ${count}/${games.length} games`);
|
||||
}
|
||||
}).catch(err => console.error('[GameLibrary] IGDB auto-enrichment error:', err));
|
||||
|
||||
const personaName = profile.personaName;
|
||||
console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`);
|
||||
|
||||
|
|
@ -283,6 +300,7 @@ const gameLibraryPlugin: Plugin = {
|
|||
appid: refGame.appid,
|
||||
name: refGame.name,
|
||||
img_icon_url: refGame.img_icon_url,
|
||||
...(refGame.igdb ? { igdb: refGame.igdb } : {}),
|
||||
owners,
|
||||
};
|
||||
});
|
||||
|
|
@ -330,6 +348,89 @@ const gameLibraryPlugin: Plugin = {
|
|||
res.json({ games, count: games.length });
|
||||
});
|
||||
|
||||
// ── GET /api/game-library/igdb/enrich/:steamId ──
|
||||
app.get('/api/game-library/igdb/enrich/:steamId', async (req, res) => {
|
||||
try {
|
||||
const { steamId } = req.params;
|
||||
const force = req.query.force === 'true';
|
||||
const data = loadData(ctx);
|
||||
const user = data.users[steamId];
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const gamesToEnrich = force
|
||||
? user.games.map(g => ({ appid: g.appid, name: g.name }))
|
||||
: user.games.filter(g => !g.igdb).map(g => ({ appid: g.appid, name: g.name }));
|
||||
|
||||
const igdbMap = await enrichGames(gamesToEnrich);
|
||||
|
||||
// Reload data to avoid stale writes
|
||||
const freshData = loadData(ctx);
|
||||
const freshUser = freshData.users[steamId];
|
||||
if (!freshUser) {
|
||||
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let enriched = 0;
|
||||
for (const game of freshUser.games) {
|
||||
const info = igdbMap.get(game.appid);
|
||||
if (info) {
|
||||
game.igdb = info;
|
||||
enriched++;
|
||||
}
|
||||
}
|
||||
|
||||
saveData(ctx, freshData);
|
||||
console.log(`[GameLibrary] IGDB enrichment for ${freshUser.personaName}: ${enriched}/${freshUser.games.length} games`);
|
||||
|
||||
res.json({ enriched, total: freshUser.games.length });
|
||||
} catch (err) {
|
||||
console.error('[GameLibrary] IGDB enrich error:', err);
|
||||
res.status(500).json({ error: 'Fehler bei der IGDB-Anreicherung.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/game-library/igdb/game/:appid ──
|
||||
app.get('/api/game-library/igdb/game/:appid', async (req, res) => {
|
||||
try {
|
||||
const appid = parseInt(req.params.appid, 10);
|
||||
if (isNaN(appid)) {
|
||||
res.status(400).json({ error: 'Ungueltige App-ID.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find game name from any user's library
|
||||
const data = loadData(ctx);
|
||||
let gameName = '';
|
||||
for (const user of Object.values(data.users)) {
|
||||
const game = user.games.find(g => g.appid === appid);
|
||||
if (game) {
|
||||
gameName = game.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!gameName) {
|
||||
res.status(404).json({ error: 'Spiel nicht in der Bibliothek gefunden.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await lookupGame(appid, gameName);
|
||||
if (!info) {
|
||||
res.status(404).json({ error: 'Spiel nicht in IGDB gefunden.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(info);
|
||||
} catch (err) {
|
||||
console.error('[GameLibrary] IGDB game lookup error:', err);
|
||||
res.status(500).json({ error: 'Fehler bei der IGDB-Abfrage.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/game-library/refresh/:steamId ──
|
||||
app.post('/api/game-library/refresh/:steamId', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -358,6 +459,21 @@ const gameLibraryPlugin: Plugin = {
|
|||
saveData(ctx, data);
|
||||
broadcastUpdate(data);
|
||||
|
||||
// Fire-and-forget IGDB enrichment
|
||||
enrichGames(games.map(g => ({ appid: g.appid, name: g.name }))).then(igdbMap => {
|
||||
const data2 = loadData(ctx);
|
||||
const user2 = data2.users[steamId];
|
||||
if (user2) {
|
||||
let count = 0;
|
||||
for (const game of user2.games) {
|
||||
const info = igdbMap.get(game.appid);
|
||||
if (info) { game.igdb = info; count++; }
|
||||
}
|
||||
saveData(ctx, data2);
|
||||
console.log(`[GameLibrary] IGDB auto-enrichment: ${count}/${games.length} games`);
|
||||
}
|
||||
}).catch(err => console.error('[GameLibrary] IGDB auto-enrichment error:', err));
|
||||
|
||||
console.log(`[GameLibrary] Aktualisiert: ${profile.personaName} (${steamId}) - ${games.length} Spiele`);
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export function registerTab(pluginName: string, component: React.FC<{ data: any
|
|||
tabComponents[pluginName] = component;
|
||||
}
|
||||
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'upToDate' | 'error';
|
||||
|
||||
export default function App() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
|
|
@ -37,6 +39,12 @@ export default function App() {
|
|||
};
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [pluginData, setPluginData] = useState<Record<string, any>>({});
|
||||
|
||||
// Electron auto-update state
|
||||
const isElectron = !!(window as any).electronAPI?.isElectron;
|
||||
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updateError, setUpdateError] = useState<string>('');
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Request notification permission
|
||||
|
|
@ -46,6 +54,19 @@ export default function App() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Electron auto-update listeners
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const api = (window as any).electronAPI;
|
||||
api.onUpdateAvailable(() => setUpdateStatus('downloading'));
|
||||
api.onUpdateReady(() => setUpdateStatus('ready'));
|
||||
api.onUpdateNotAvailable(() => setUpdateStatus('upToDate'));
|
||||
api.onUpdateError((msg: string) => {
|
||||
setUpdateStatus('error');
|
||||
setUpdateError(msg || 'Unbekannter Fehler');
|
||||
});
|
||||
}, [isElectron]);
|
||||
|
||||
// Fetch plugin list
|
||||
useEffect(() => {
|
||||
fetch('/api/plugins')
|
||||
|
|
@ -184,9 +205,15 @@ export default function App() {
|
|||
</div>
|
||||
<div className="hub-version-modal-body">
|
||||
<div className="hub-version-modal-row">
|
||||
<span className="hub-version-modal-label">Version</span>
|
||||
<span className="hub-version-modal-label">Hub-Version</span>
|
||||
<span className="hub-version-modal-value">v{version}</span>
|
||||
</div>
|
||||
{isElectron && (
|
||||
<div className="hub-version-modal-row">
|
||||
<span className="hub-version-modal-label">Desktop-App</span>
|
||||
<span className="hub-version-modal-value">v{electronVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hub-version-modal-row">
|
||||
<span className="hub-version-modal-label">Server</span>
|
||||
<span className="hub-version-modal-value">
|
||||
|
|
@ -194,6 +221,65 @@ export default function App() {
|
|||
{connected ? 'Verbunden' : 'Getrennt'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isElectron && (
|
||||
<div className="hub-version-modal-update">
|
||||
{updateStatus === 'idle' && (
|
||||
<button
|
||||
className="hub-version-modal-update-btn"
|
||||
onClick={() => {
|
||||
setUpdateStatus('checking');
|
||||
setUpdateError('');
|
||||
(window as any).electronAPI.checkForUpdates();
|
||||
}}
|
||||
>
|
||||
{'\u{1F504}'} Nach Updates suchen
|
||||
</button>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
<div className="hub-version-modal-update-status">
|
||||
<span className="hub-update-spinner" />
|
||||
Suche nach Updates…
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'downloading' && (
|
||||
<div className="hub-version-modal-update-status">
|
||||
<span className="hub-update-spinner" />
|
||||
Update wird heruntergeladen…
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
<button
|
||||
className="hub-version-modal-update-btn ready"
|
||||
onClick={() => (window as any).electronAPI.installUpdate()}
|
||||
>
|
||||
{'\u2705'} Jetzt installieren & neu starten
|
||||
</button>
|
||||
)}
|
||||
{updateStatus === 'upToDate' && (
|
||||
<div className="hub-version-modal-update-status success">
|
||||
{'\u2705'} App ist aktuell
|
||||
<button
|
||||
className="hub-version-modal-update-retry"
|
||||
onClick={() => setUpdateStatus('idle')}
|
||||
>
|
||||
Erneut prüfen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
<div className="hub-version-modal-update-status error">
|
||||
{'\u274C'} {updateError}
|
||||
<button
|
||||
className="hub-version-modal-update-retry"
|
||||
onClick={() => setUpdateStatus('idle')}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,17 +13,31 @@ interface UserSummary {
|
|||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface IgdbData {
|
||||
igdbId: number;
|
||||
name: string;
|
||||
coverUrl: string | null;
|
||||
genres: string[];
|
||||
platforms: string[];
|
||||
rating: number | null;
|
||||
firstReleaseDate: string | null;
|
||||
summary: string | null;
|
||||
igdbUrl: string | null;
|
||||
}
|
||||
|
||||
interface SteamGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbData;
|
||||
}
|
||||
|
||||
interface CommonGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbData;
|
||||
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>;
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +92,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [enriching, setEnriching] = useState<string | null>(null);
|
||||
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -153,6 +168,21 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
}
|
||||
}, [fetchUsers, mode, selectedUser, viewUser]);
|
||||
|
||||
// ── Enrich user library with IGDB data ──
|
||||
const enrichUser = useCallback(async (steamId: string) => {
|
||||
setEnriching(steamId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setEnriching(null); }
|
||||
}, [mode, selectedUser, viewUser]);
|
||||
|
||||
// ── Toggle user selection for common games ──
|
||||
const toggleCommonUser = useCallback((steamId: string) => {
|
||||
setSelectedUsers(prev => {
|
||||
|
|
@ -408,6 +438,14 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
className="gl-enrich-btn"
|
||||
onClick={() => enrichUser(selectedUser!)}
|
||||
disabled={enriching === selectedUser}
|
||||
title="Mit IGDB-Daten anreichern"
|
||||
>
|
||||
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -431,20 +469,44 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
) : (
|
||||
<div className="gl-game-list">
|
||||
{filteredGames.map(g => (
|
||||
<div key={g.appid} className="gl-game-item">
|
||||
{g.img_icon_url ? (
|
||||
<img
|
||||
className="gl-game-icon"
|
||||
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
<span className="gl-game-playtime">
|
||||
{formatPlaytime(g.playtime_forever)}
|
||||
</span>
|
||||
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||
{/* Cover/Icon */}
|
||||
<div className="gl-game-visual">
|
||||
{g.igdb?.coverUrl ? (
|
||||
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
|
||||
) : g.img_icon_url ? (
|
||||
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="gl-game-info">
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
{g.igdb?.genres && g.igdb.genres.length > 0 && (
|
||||
<div className="gl-game-genres">
|
||||
{g.igdb.genres.slice(0, 3).map(genre => (
|
||||
<span key={genre} className="gl-genre-tag">{genre}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{g.igdb?.platforms && g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).length > 0 && (
|
||||
<div className="gl-game-platforms">
|
||||
Auch auf: {g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).slice(0, 3).map(p => (
|
||||
<span key={p} className="gl-platform-tag">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right side: rating + playtime */}
|
||||
<div className="gl-game-meta">
|
||||
{g.igdb?.rating != null && (
|
||||
<span className={`gl-game-rating ${g.igdb.rating >= 75 ? 'high' : g.igdb.rating >= 50 ? 'mid' : 'low'}`}>
|
||||
{Math.round(g.igdb.rating)}
|
||||
</span>
|
||||
)}
|
||||
<span className="gl-game-playtime">{formatPlaytime(g.playtime_forever)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -494,16 +556,16 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
</p>
|
||||
<div className="gl-game-list">
|
||||
{commonGames.map(g => (
|
||||
<div key={g.appid} className="gl-game-item">
|
||||
{g.img_icon_url ? (
|
||||
<img
|
||||
className="gl-game-icon"
|
||||
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||
<div className="gl-game-visual">
|
||||
{g.igdb?.coverUrl ? (
|
||||
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
|
||||
) : g.img_icon_url ? (
|
||||
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
</div>
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
<div className="gl-common-playtimes">
|
||||
{g.owners.map(o => (
|
||||
|
|
|
|||
|
|
@ -447,6 +447,112 @@
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── IGDB Enriched game items ── */
|
||||
|
||||
.gl-game-item.enriched {
|
||||
align-items: flex-start;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.gl-game-visual {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-cover {
|
||||
width: 45px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gl-game-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-game-genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-genre-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
.gl-game-platforms {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.gl-platform-tag {
|
||||
margin-left: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.gl-game-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-rating {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gl-game-rating.high {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.gl-game-rating.mid {
|
||||
background: rgba(241, 196, 15, 0.2);
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.gl-game-rating.low {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.gl-enrich-btn {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
color: #3498db;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gl-enrich-btn:hover:not(:disabled) {
|
||||
background: rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
.gl-enrich-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -443,6 +443,82 @@ html, body {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Update Section in Version Modal ── */
|
||||
.hub-version-modal-update {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.hub-version-modal-update-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-normal);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.hub-version-modal-update-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
.hub-version-modal-update-btn.ready {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #2ecc71;
|
||||
}
|
||||
.hub-version-modal-update-btn.ready:hover {
|
||||
background: rgba(46, 204, 113, 0.25);
|
||||
}
|
||||
.hub-version-modal-update-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hub-version-modal-update-status.success {
|
||||
color: #2ecc71;
|
||||
}
|
||||
.hub-version-modal-update-status.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
.hub-version-modal-update-retry {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 2px 4px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hub-version-modal-update-retry:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
@keyframes hub-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hub-update-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: hub-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main Content Area ── */
|
||||
.hub-content {
|
||||
flex: 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue