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
|
|
@ -13,17 +13,31 @@ interface UserSummary {
|
|||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface IgdbData {
|
||||
igdbId: number;
|
||||
name: string;
|
||||
coverUrl: string | null;
|
||||
genres: string[];
|
||||
platforms: string[];
|
||||
rating: number | null;
|
||||
firstReleaseDate: string | null;
|
||||
summary: string | null;
|
||||
igdbUrl: string | null;
|
||||
}
|
||||
|
||||
interface SteamGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbData;
|
||||
}
|
||||
|
||||
interface CommonGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbData;
|
||||
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>;
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +92,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [enriching, setEnriching] = useState<string | null>(null);
|
||||
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -153,6 +168,21 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
}
|
||||
}, [fetchUsers, mode, selectedUser, viewUser]);
|
||||
|
||||
// ── Enrich user library with IGDB data ──
|
||||
const enrichUser = useCallback(async (steamId: string) => {
|
||||
setEnriching(steamId);
|
||||
try {
|
||||
const resp = await fetch(`/api/game-library/igdb/enrich/${steamId}`);
|
||||
if (resp.ok) {
|
||||
// Reload user's game data to get IGDB info
|
||||
if (mode === 'user' && selectedUser === steamId) {
|
||||
viewUser(steamId);
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setEnriching(null); }
|
||||
}, [mode, selectedUser, viewUser]);
|
||||
|
||||
// ── Toggle user selection for common games ──
|
||||
const toggleCommonUser = useCallback((steamId: string) => {
|
||||
setSelectedUsers(prev => {
|
||||
|
|
@ -408,6 +438,14 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
className="gl-enrich-btn"
|
||||
onClick={() => enrichUser(selectedUser!)}
|
||||
disabled={enriching === selectedUser}
|
||||
title="Mit IGDB-Daten anreichern"
|
||||
>
|
||||
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -431,20 +469,44 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
) : (
|
||||
<div className="gl-game-list">
|
||||
{filteredGames.map(g => (
|
||||
<div key={g.appid} className="gl-game-item">
|
||||
{g.img_icon_url ? (
|
||||
<img
|
||||
className="gl-game-icon"
|
||||
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
<span className="gl-game-playtime">
|
||||
{formatPlaytime(g.playtime_forever)}
|
||||
</span>
|
||||
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||
{/* Cover/Icon */}
|
||||
<div className="gl-game-visual">
|
||||
{g.igdb?.coverUrl ? (
|
||||
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
|
||||
) : g.img_icon_url ? (
|
||||
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="gl-game-info">
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
{g.igdb?.genres && g.igdb.genres.length > 0 && (
|
||||
<div className="gl-game-genres">
|
||||
{g.igdb.genres.slice(0, 3).map(genre => (
|
||||
<span key={genre} className="gl-genre-tag">{genre}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{g.igdb?.platforms && g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).length > 0 && (
|
||||
<div className="gl-game-platforms">
|
||||
Auch auf: {g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).slice(0, 3).map(p => (
|
||||
<span key={p} className="gl-platform-tag">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right side: rating + playtime */}
|
||||
<div className="gl-game-meta">
|
||||
{g.igdb?.rating != null && (
|
||||
<span className={`gl-game-rating ${g.igdb.rating >= 75 ? 'high' : g.igdb.rating >= 50 ? 'mid' : 'low'}`}>
|
||||
{Math.round(g.igdb.rating)}
|
||||
</span>
|
||||
)}
|
||||
<span className="gl-game-playtime">{formatPlaytime(g.playtime_forever)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -494,16 +556,16 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
</p>
|
||||
<div className="gl-game-list">
|
||||
{commonGames.map(g => (
|
||||
<div key={g.appid} className="gl-game-item">
|
||||
{g.img_icon_url ? (
|
||||
<img
|
||||
className="gl-game-icon"
|
||||
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||
<div className="gl-game-visual">
|
||||
{g.igdb?.coverUrl ? (
|
||||
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
|
||||
) : g.img_icon_url ? (
|
||||
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
</div>
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
<div className="gl-common-playtimes">
|
||||
{g.owners.map(o => (
|
||||
|
|
|
|||
|
|
@ -447,6 +447,112 @@
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── IGDB Enriched game items ── */
|
||||
|
||||
.gl-game-item.enriched {
|
||||
align-items: flex-start;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.gl-game-visual {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-cover {
|
||||
width: 45px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gl-game-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-game-genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-genre-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
.gl-game-platforms {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.gl-platform-tag {
|
||||
margin-left: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.gl-game-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-rating {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gl-game-rating.high {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.gl-game-rating.mid {
|
||||
background: rgba(241, 196, 15, 0.2);
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.gl-game-rating.low {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.gl-enrich-btn {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
color: #3498db;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gl-enrich-btn:hover:not(:disabled) {
|
||||
background: rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
.gl-enrich-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue