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 { 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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</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" />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Info */}
|
||||||
|
<div className="gl-game-info">
|
||||||
<span className="gl-game-name">{g.name}</span>
|
<span className="gl-game-name">{g.name}</span>
|
||||||
<span className="gl-game-playtime">
|
{g.igdb?.genres && g.igdb.genres.length > 0 && (
|
||||||
{formatPlaytime(g.playtime_forever)}
|
<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>
|
||||||
|
)}
|
||||||
|
<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 => (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue