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:
Daniel 2026-03-08 01:48:15 +01:00
parent aec1142bff
commit b404c20eca
6 changed files with 785 additions and 25 deletions

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

View file

@ -3,6 +3,7 @@ import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js';
// ── Types ── // ── Types ──
@ -11,6 +12,7 @@ interface SteamGame {
name: string; name: string;
playtime_forever: number; // minutes playtime_forever: number; // minutes
img_icon_url: string; img_icon_url: string;
igdb?: IgdbGameInfo;
} }
interface SteamUser { interface SteamUser {
@ -185,6 +187,21 @@ const gameLibraryPlugin: Plugin = {
saveData(ctx, data); saveData(ctx, data);
broadcastUpdate(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; const personaName = profile.personaName;
console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`); console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`);
@ -283,6 +300,7 @@ const gameLibraryPlugin: Plugin = {
appid: refGame.appid, appid: refGame.appid,
name: refGame.name, name: refGame.name,
img_icon_url: refGame.img_icon_url, img_icon_url: refGame.img_icon_url,
...(refGame.igdb ? { igdb: refGame.igdb } : {}),
owners, owners,
}; };
}); });
@ -330,6 +348,89 @@ const gameLibraryPlugin: Plugin = {
res.json({ games, count: games.length }); 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 ── // ── POST /api/game-library/refresh/:steamId ──
app.post('/api/game-library/refresh/:steamId', async (req, res) => { app.post('/api/game-library/refresh/:steamId', async (req, res) => {
try { try {
@ -358,6 +459,21 @@ const gameLibraryPlugin: Plugin = {
saveData(ctx, data); saveData(ctx, data);
broadcastUpdate(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`); console.log(`[GameLibrary] Aktualisiert: ${profile.personaName} (${steamId}) - ${games.length} Spiele`);
res.json({ res.json({

View file

@ -26,6 +26,8 @@ export function registerTab(pluginName: string, component: React.FC<{ data: any
tabComponents[pluginName] = component; tabComponents[pluginName] = component;
} }
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'upToDate' | 'error';
export default function App() { export default function App() {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [plugins, setPlugins] = useState<PluginInfo[]>([]); const [plugins, setPlugins] = useState<PluginInfo[]>([]);
@ -37,6 +39,12 @@ export default function App() {
}; };
const [showVersionModal, setShowVersionModal] = useState(false); const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({}); 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); const eventSourceRef = useRef<EventSource | null>(null);
// Request notification permission // 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 // Fetch plugin list
useEffect(() => { useEffect(() => {
fetch('/api/plugins') fetch('/api/plugins')
@ -184,9 +205,15 @@ export default function App() {
</div> </div>
<div className="hub-version-modal-body"> <div className="hub-version-modal-body">
<div className="hub-version-modal-row"> <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> <span className="hub-version-modal-value">v{version}</span>
</div> </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"> <div className="hub-version-modal-row">
<span className="hub-version-modal-label">Server</span> <span className="hub-version-modal-label">Server</span>
<span className="hub-version-modal-value"> <span className="hub-version-modal-value">
@ -194,6 +221,65 @@ export default function App() {
{connected ? 'Verbunden' : 'Getrennt'} {connected ? 'Verbunden' : 'Getrennt'}
</span> </span>
</div> </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> </div>
</div> </div>

View file

@ -13,17 +13,31 @@ interface UserSummary {
lastUpdated: string; 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 { interface SteamGame {
appid: number; appid: number;
name: string; name: string;
playtime_forever: number; playtime_forever: number;
img_icon_url: string; img_icon_url: string;
igdb?: IgdbData;
} }
interface CommonGame { interface CommonGame {
appid: number; appid: number;
name: string; name: string;
img_icon_url: string; img_icon_url: string;
igdb?: IgdbData;
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>; 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 [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [enriching, setEnriching] = useState<string | null>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const filterInputRef = useRef<HTMLInputElement>(null); const filterInputRef = useRef<HTMLInputElement>(null);
@ -153,6 +168,21 @@ export default function GameLibraryTab({ data }: { data: any }) {
} }
}, [fetchUsers, mode, selectedUser, viewUser]); }, [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 ── // ── Toggle user selection for common games ──
const toggleCommonUser = useCallback((steamId: string) => { const toggleCommonUser = useCallback((steamId: string) => {
setSelectedUsers(prev => { setSelectedUsers(prev => {
@ -408,6 +438,14 @@ export default function GameLibraryTab({ data }: { data: any }) {
> >
&#x21bb; &#x21bb;
</button> </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> </div>
@ -431,20 +469,44 @@ export default function GameLibraryTab({ data }: { data: any }) {
) : ( ) : (
<div className="gl-game-list"> <div className="gl-game-list">
{filteredGames.map(g => ( {filteredGames.map(g => (
<div key={g.appid} className="gl-game-item"> <div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{g.img_icon_url ? ( {/* Cover/Icon */}
<img <div className="gl-game-visual">
className="gl-game-icon" {g.igdb?.coverUrl ? (
src={gameIconUrl(g.appid, g.img_icon_url)} <img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
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 className="gl-game-icon" />
)} )}
<span className="gl-game-name">{g.name}</span> </div>
<span className="gl-game-playtime"> {/* Info */}
{formatPlaytime(g.playtime_forever)} <div className="gl-game-info">
</span> <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>
))} ))}
</div> </div>
@ -494,16 +556,16 @@ export default function GameLibraryTab({ data }: { data: any }) {
</p> </p>
<div className="gl-game-list"> <div className="gl-game-list">
{commonGames.map(g => ( {commonGames.map(g => (
<div key={g.appid} className="gl-game-item"> <div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{g.img_icon_url ? ( <div className="gl-game-visual">
<img {g.igdb?.coverUrl ? (
className="gl-game-icon" <img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
src={gameIconUrl(g.appid, g.img_icon_url)} ) : g.img_icon_url ? (
alt="" <img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
/> ) : (
) : ( <div className="gl-game-icon" />
<div className="gl-game-icon" /> )}
)} </div>
<span className="gl-game-name">{g.name}</span> <span className="gl-game-name">{g.name}</span>
<div className="gl-common-playtimes"> <div className="gl-common-playtimes">
{g.owners.map(o => ( {g.owners.map(o => (

View file

@ -447,6 +447,112 @@
margin-bottom: 8px; 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 ── */ /* ── Responsive ── */
@media (max-width: 768px) { @media (max-width: 768px) {

View file

@ -443,6 +443,82 @@ html, body {
text-align: center; 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 ── */ /* ── Main Content Area ── */
.hub-content { .hub-content {
flex: 1; flex: 1;