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

@ -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>

View file

@ -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 }) {
>
&#x21bb;
</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 => (

View file

@ -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) {

View file

@ -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;