From b404c20eca647a7b43c21c8957cd462adb99d780 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 8 Mar 2026 01:48:15 +0100 Subject: [PATCH] =?UTF-8?q?IGDB-Integration=20f=C3=BCr=20Game=20Library=20?= =?UTF-8?q?+=20Electron=20Update-Button=20im=20Version-Modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/src/plugins/game-library/igdb.ts | 314 ++++++++++++++++++ server/src/plugins/game-library/index.ts | 116 +++++++ web/src/App.tsx | 88 ++++- .../plugins/game-library/GameLibraryTab.tsx | 110 ++++-- web/src/plugins/game-library/game-library.css | 106 ++++++ web/src/styles.css | 76 +++++ 6 files changed, 785 insertions(+), 25 deletions(-) create mode 100644 server/src/plugins/game-library/igdb.ts diff --git a/server/src/plugins/game-library/igdb.ts b/server/src/plugins/game-library/igdb.ts new file mode 100644 index 0000000..ff48eb2 --- /dev/null +++ b/server/src/plugins/game-library/igdb.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + +/** + * 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> { + const result = new Map(); + 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; +} diff --git a/server/src/plugins/game-library/index.ts b/server/src/plugins/game-library/index.ts index d085fa0..a186f47 100644 --- a/server/src/plugins/game-library/index.ts +++ b/server/src/plugins/game-library/index.ts @@ -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({ diff --git a/web/src/App.tsx b/web/src/App.tsx index 8c8d132..d864dd1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]); @@ -37,6 +39,12 @@ export default function App() { }; const [showVersionModal, setShowVersionModal] = useState(false); const [pluginData, setPluginData] = useState>({}); + + // Electron auto-update state + const isElectron = !!(window as any).electronAPI?.isElectron; + const electronVersion = isElectron ? (window as any).electronAPI.version : null; + const [updateStatus, setUpdateStatus] = useState('idle'); + const [updateError, setUpdateError] = useState(''); const eventSourceRef = useRef(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() {
- Version + Hub-Version v{version}
+ {isElectron && ( +
+ Desktop-App + v{electronVersion} +
+ )}
Server @@ -194,6 +221,65 @@ export default function App() { {connected ? 'Verbunden' : 'Getrennt'}
+ + {isElectron && ( +
+ {updateStatus === 'idle' && ( + + )} + {updateStatus === 'checking' && ( +
+ + Suche nach Updates… +
+ )} + {updateStatus === 'downloading' && ( +
+ + Update wird heruntergeladen… +
+ )} + {updateStatus === 'ready' && ( + + )} + {updateStatus === 'upToDate' && ( +
+ {'\u2705'} App ist aktuell + +
+ )} + {updateStatus === 'error' && ( +
+ {'\u274C'} {updateError} + +
+ )} +
+ )}
diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx index b8a7027..576946b 100644 --- a/web/src/plugins/game-library/GameLibraryTab.tsx +++ b/web/src/plugins/game-library/GameLibraryTab.tsx @@ -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(null); const [loading, setLoading] = useState(false); + const [enriching, setEnriching] = useState(null); const searchTimerRef = useRef | null>(null); const filterInputRef = useRef(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 }) { > ↻ + )} @@ -431,20 +469,44 @@ export default function GameLibraryTab({ data }: { data: any }) { ) : (
{filteredGames.map(g => ( -
- {g.img_icon_url ? ( - - ) : ( -
- )} - {g.name} - - {formatPlaytime(g.playtime_forever)} - +
+ {/* Cover/Icon */} +
+ {g.igdb?.coverUrl ? ( + + ) : g.img_icon_url ? ( + + ) : ( +
+ )} +
+ {/* Info */} +
+ {g.name} + {g.igdb?.genres && g.igdb.genres.length > 0 && ( +
+ {g.igdb.genres.slice(0, 3).map(genre => ( + {genre} + ))} +
+ )} + {g.igdb?.platforms && g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).length > 0 && ( +
+ Auch auf: {g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).slice(0, 3).map(p => ( + {p} + ))} +
+ )} +
+ {/* Right side: rating + playtime */} +
+ {g.igdb?.rating != null && ( + = 75 ? 'high' : g.igdb.rating >= 50 ? 'mid' : 'low'}`}> + {Math.round(g.igdb.rating)} + + )} + {formatPlaytime(g.playtime_forever)} +
))}
@@ -494,16 +556,16 @@ export default function GameLibraryTab({ data }: { data: any }) {

{commonGames.map(g => ( -
- {g.img_icon_url ? ( - - ) : ( -
- )} +
+
+ {g.igdb?.coverUrl ? ( + + ) : g.img_icon_url ? ( + + ) : ( +
+ )} +
{g.name}
{g.owners.map(o => ( diff --git a/web/src/plugins/game-library/game-library.css b/web/src/plugins/game-library/game-library.css index 3ab830e..f8f0cd2 100644 --- a/web/src/plugins/game-library/game-library.css +++ b/web/src/plugins/game-library/game-library.css @@ -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) { diff --git a/web/src/styles.css b/web/src/styles.css index a6b495c..a4e1cb8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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;