Game Library: Multi-Platform Profile System + GOG Integration

- Neues Profile-System: User können Steam und GOG verknüpfen
- GOG OAuth2 Login-Flow (auth.gog.com)
- GOG API Service (gog.ts): Token-Management, Spieleliste, User-Info
- Server: Profile-Datenmodell, Migration bestehender Steam-Users
- Frontend: Login-Bar (Steam + GOG), Profile-Chips mit Platform-Badges
- Cross-Platform Game-Merging mit Deduplizierung
- Profile-Detail mit "GOG verknüpfen" Option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 12:34:43 +01:00
parent 89e655482b
commit ee5c29dd7b
4 changed files with 859 additions and 189 deletions

View file

@ -5,14 +5,6 @@ import './game-library.css';
TYPES
*/
interface UserSummary {
steamId: string;
personaName: string;
avatarUrl: string;
gameCount: number;
lastUpdated: string;
}
interface IgdbData {
igdbId: number;
name: string;
@ -25,11 +17,26 @@ interface IgdbData {
igdbUrl: string | null;
}
interface SteamGame {
appid: number;
interface ProfileSummary {
id: string;
displayName: string;
avatarUrl: string;
platforms: {
steam: { steamId: string; personaName: string; gameCount: number } | null;
gog: { gogUserId: string; username: string; gameCount: number } | null;
};
totalGames: number;
lastUpdated: string;
}
interface MergedGame {
name: string;
playtime_forever: number;
img_icon_url: string;
platform: 'steam' | 'gog';
appid?: number;
gogId?: number;
playtime_forever?: number;
img_icon_url?: string;
image?: string;
igdb?: IgdbData;
}
@ -56,7 +63,8 @@ function gameIconUrl(appid: number, hash: string): string {
return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`;
}
function formatPlaytime(minutes: number): string {
function formatPlaytime(minutes: number | undefined): string {
if (minutes == null || minutes === 0) return '—';
if (minutes < 60) return `${minutes} Min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
@ -83,11 +91,11 @@ function formatDate(iso: string): string {
export default function GameLibraryTab({ data }: { data: any }) {
// ── State ──
const [users, setUsers] = useState<UserSummary[]>([]);
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [userGames, setUserGames] = useState<SteamGame[] | null>(null);
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [userGames, setUserGames] = useState<MergedGame[] | null>(null);
const [commonGames, setCommonGames] = useState<CommonGame[] | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
@ -103,20 +111,18 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── SSE data sync ──
useEffect(() => {
if (data?.users) setUsers(data.users);
if (data?.profiles) setProfiles(data.profiles);
}, [data]);
// ── Refetch users ──
const fetchUsers = useCallback(async () => {
// ── Refetch profiles ──
const fetchProfiles = useCallback(async () => {
try {
const resp = await fetch('/api/game-library/users');
const resp = await fetch('/api/game-library/profiles');
if (resp.ok) {
const d = await resp.json();
setUsers(d.users || []);
setProfiles(d.profiles || []);
}
} catch {
/* silent */
}
} catch { /* silent */ }
}, []);
// ── Steam login ──
@ -125,45 +131,63 @@ export default function GameLibraryTab({ data }: { data: any }) {
const interval = setInterval(() => {
if (w && w.closed) {
clearInterval(interval);
setTimeout(fetchUsers, 1000);
setTimeout(fetchProfiles, 1000);
}
}, 500);
}, [fetchUsers]);
}, [fetchProfiles]);
// ── GOG login ──
const connectGog = useCallback(() => {
// If viewing a profile, pass linkTo param so GOG gets linked to it
const linkParam = selectedProfile ? `?linkTo=${selectedProfile}` : '';
const w = window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=600');
const interval = setInterval(() => {
if (w && w.closed) {
clearInterval(interval);
setTimeout(fetchProfiles, 1500);
}
}, 500);
}, [fetchProfiles, selectedProfile]);
// ── Refetch on window focus (after login redirect) ──
useEffect(() => {
const onFocus = () => fetchUsers();
const onFocus = () => fetchProfiles();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [fetchUsers]);
}, [fetchProfiles]);
// ── View user library ──
const viewUser = useCallback(async (steamId: string) => {
// ── View profile library ──
const viewProfile = useCallback(async (profileId: string) => {
setMode('user');
setSelectedUser(steamId);
setSelectedProfile(profileId);
setUserGames(null);
setFilterQuery('');
setLoading(true);
try {
const resp = await fetch(`/api/game-library/user/${steamId}`);
const resp = await fetch(`/api/game-library/profile/${profileId}/games`);
if (resp.ok) {
const d = await resp.json();
const games: SteamGame[] = d.games || d;
const games: MergedGame[] = d.games || d;
setUserGames(games);
// Auto-enrich with IGDB if many games lack data
const unenriched = games.filter(g => !g.igdb).length;
if (unenriched > 0) {
setEnriching(steamId);
fetch(`/api/game-library/igdb/enrich/${steamId}`)
.then(r => r.ok ? r.json() : null)
.then(() => fetch(`/api/game-library/user/${steamId}`))
.then(r => r.ok ? r.json() : null)
.then(d2 => {
if (d2) setUserGames(d2.games || d2);
})
.catch(() => {})
.finally(() => setEnriching(null));
// Find steamId from profile to use for enrichment
const profile = profiles.find(p => p.id === profileId);
const steamId = profile?.platforms?.steam?.steamId;
if (steamId) {
const unenriched = games.filter(g => !g.igdb).length;
if (unenriched > 0) {
setEnriching(profileId);
fetch(`/api/game-library/igdb/enrich/${steamId}`)
.then(r => r.ok ? r.json() : null)
.then(() => fetch(`/api/game-library/profile/${profileId}/games`))
.then(r => r.ok ? r.json() : null)
.then(d2 => {
if (d2) setUserGames(d2.games || d2);
})
.catch(() => {})
.finally(() => setEnriching(null));
}
}
}
} catch {
@ -171,55 +195,62 @@ export default function GameLibraryTab({ data }: { data: any }) {
} finally {
setLoading(false);
}
}, []);
}, [profiles]);
// ── Refresh single user ──
const refreshUser = useCallback(async (steamId: string, e?: React.MouseEvent) => {
// ── Refresh single profile ──
const refreshProfile = useCallback(async (profileId: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
const profile = profiles.find(p => p.id === profileId);
const steamId = profile?.platforms?.steam?.steamId;
try {
await fetch(`/api/game-library/user/${steamId}?refresh=true`);
await fetchUsers();
if (mode === 'user' && selectedUser === steamId) {
viewUser(steamId);
if (steamId) {
await fetch(`/api/game-library/user/${steamId}?refresh=true`);
}
await fetchProfiles();
if (mode === 'user' && selectedProfile === profileId) {
viewProfile(profileId);
}
} catch {
/* silent */
}
}, [fetchUsers, mode, selectedUser, viewUser]);
}, [fetchProfiles, mode, selectedProfile, viewProfile, profiles]);
// ── Enrich user library with IGDB data ──
const enrichUser = useCallback(async (steamId: string) => {
setEnriching(steamId);
// ── Enrich profile library with IGDB data ──
const enrichProfile = useCallback(async (profileId: string) => {
const profile = profiles.find(p => p.id === profileId);
const steamId = profile?.platforms?.steam?.steamId;
if (!steamId) return;
setEnriching(profileId);
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);
// Reload profile's game data to get IGDB info
if (mode === 'user' && selectedProfile === profileId) {
viewProfile(profileId);
}
}
} catch { /* silent */ }
finally { setEnriching(null); }
}, [mode, selectedUser, viewUser]);
}, [mode, selectedProfile, viewProfile, profiles]);
// ── Toggle user selection for common games ──
const toggleCommonUser = useCallback((steamId: string) => {
setSelectedUsers(prev => {
// ── Toggle profile selection for common games ──
const toggleCommonProfile = useCallback((profileId: string) => {
setSelectedProfiles(prev => {
const next = new Set(prev);
if (next.has(steamId)) next.delete(steamId);
else next.add(steamId);
if (next.has(profileId)) next.delete(profileId);
else next.add(profileId);
return next;
});
}, []);
// ── Find common games ──
const findCommonGames = useCallback(async () => {
if (selectedUsers.size < 2) return;
if (selectedProfiles.size < 2) return;
setMode('common');
setCommonGames(null);
setLoading(true);
try {
const ids = Array.from(selectedUsers).join(',');
const ids = Array.from(selectedProfiles).join(',');
const resp = await fetch(`/api/game-library/common-games?users=${ids}`);
if (resp.ok) {
const d = await resp.json();
@ -230,7 +261,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
} finally {
setLoading(false);
}
}, [selectedUsers]);
}, [selectedProfiles]);
// ── Search (debounced) ──
const handleSearch = useCallback((value: string) => {
@ -266,7 +297,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── Back to overview ──
const goBack = useCallback(() => {
setMode('overview');
setSelectedUser(null);
setSelectedProfile(null);
setUserGames(null);
setCommonGames(null);
setFilterQuery('');
@ -274,10 +305,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
setSortBy('playtime');
}, []);
// ── Resolve user by steamId ──
const getUser = useCallback(
(steamId: string) => users.find(u => u.steamId === steamId),
[users],
// ── Resolve profile by id ──
const getProfile = useCallback(
(profileId: string) => profiles.find(p => p.id === profileId),
[profiles],
);
// ── Sort helper ──
@ -343,89 +374,103 @@ export default function GameLibraryTab({ data }: { data: any }) {
return (
<div className="gl-container">
{/* ── Top bar ── */}
<div className="gl-topbar">
<button className="gl-connect-btn" onClick={connectSteam}>
Mit Steam verbinden
{/* ── Login Bar ── */}
<div className="gl-login-bar">
<button className="gl-connect-btn gl-steam-btn" onClick={connectSteam}>
🎮 Steam verbinden
</button>
<div className="gl-user-chips">
{users.map(u => (
<button className="gl-connect-btn gl-gog-btn" onClick={connectGog}>
🟣 GOG verbinden
</button>
</div>
{/* ── Profile Chips ── */}
{profiles.length > 0 && (
<div className="gl-profile-chips">
{profiles.map(p => (
<div
key={u.steamId}
className={`gl-user-chip${selectedUser === u.steamId ? ' selected' : ''}`}
onClick={() => viewUser(u.steamId)}
key={p.id}
className={`gl-profile-chip${selectedProfile === p.id ? ' selected' : ''}`}
onClick={() => viewProfile(p.id)}
>
<img className="gl-user-chip-avatar" src={u.avatarUrl} alt={u.personaName} />
<span className="gl-user-chip-name">{u.personaName}</span>
<span className="gl-user-chip-count">({u.gameCount})</span>
<button
className="gl-user-chip-refresh"
onClick={e => refreshUser(u.steamId, e)}
title="Aktualisieren"
>
&#x21bb;
</button>
<img className="gl-profile-chip-avatar" src={p.avatarUrl} alt={p.displayName} />
<div className="gl-profile-chip-info">
<span className="gl-profile-chip-name">{p.displayName}</span>
<span className="gl-profile-chip-platforms">
{p.platforms.steam && <span className="gl-platform-badge steam" title={`Steam: ${p.platforms.steam.gameCount} Spiele`}>S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog" title={`GOG: ${p.platforms.gog.gameCount} Spiele`}>G</span>}
</span>
</div>
<span className="gl-profile-chip-count">({p.totalGames})</span>
</div>
))}
</div>
</div>
)}
{/* ── Overview mode ── */}
{mode === 'overview' && (
<>
{users.length === 0 ? (
{profiles.length === 0 ? (
<div className="gl-empty">
<div className="gl-empty-icon">&#x1F3AE;</div>
<h3>Keine Steam-Konten verbunden</h3>
<h3>Keine Konten verbunden</h3>
<p>
Klicke oben auf &ldquo;Mit Steam verbinden&rdquo;, um deine Spielebibliothek
hinzuzufuegen.
Klicke oben auf &ldquo;Steam verbinden&rdquo; oder &ldquo;GOG verbinden&rdquo;, um deine
Spielebibliothek hinzuzufuegen.
</p>
</div>
) : (
<>
{/* User cards */}
{/* Profile cards */}
<p className="gl-section-title">Verbundene Spieler</p>
<div className="gl-users-grid">
{users.map(u => (
<div key={u.steamId} className="gl-user-card" onClick={() => viewUser(u.steamId)}>
<img className="gl-user-card-avatar" src={u.avatarUrl} alt={u.personaName} />
<span className="gl-user-card-name">{u.personaName}</span>
<span className="gl-user-card-games">{u.gameCount} Spiele</span>
{profiles.map(p => (
<div key={p.id} className="gl-user-card" onClick={() => viewProfile(p.id)}>
<img className="gl-user-card-avatar" src={p.avatarUrl} alt={p.displayName} />
<span className="gl-user-card-name">{p.displayName}</span>
<div className="gl-profile-card-platforms">
{p.platforms.steam && <span className="gl-platform-badge steam" title="Steam">S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog" title="GOG">G</span>}
</div>
<span className="gl-user-card-games">{p.totalGames} Spiele</span>
<span className="gl-user-card-updated">
Aktualisiert: {formatDate(u.lastUpdated)}
Aktualisiert: {formatDate(p.lastUpdated)}
</span>
</div>
))}
</div>
{/* Common games finder */}
{users.length >= 2 && (
{profiles.length >= 2 && (
<div className="gl-common-finder">
<h3>Gemeinsame Spiele finden</h3>
<div className="gl-common-users">
{users.map(u => (
{profiles.map(p => (
<label
key={u.steamId}
className={`gl-common-check${selectedUsers.has(u.steamId) ? ' checked' : ''}`}
key={p.id}
className={`gl-common-check${selectedProfiles.has(p.id) ? ' checked' : ''}`}
>
<input
type="checkbox"
checked={selectedUsers.has(u.steamId)}
onChange={() => toggleCommonUser(u.steamId)}
checked={selectedProfiles.has(p.id)}
onChange={() => toggleCommonProfile(p.id)}
/>
<img
className="gl-common-check-avatar"
src={u.avatarUrl}
alt={u.personaName}
src={p.avatarUrl}
alt={p.displayName}
/>
{u.personaName}
{p.displayName}
<span className="gl-profile-chip-platforms" style={{ marginLeft: 4 }}>
{p.platforms.steam && <span className="gl-platform-badge steam">S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog">G</span>}
</span>
</label>
))}
</div>
<button
className="gl-common-find-btn"
disabled={selectedUsers.size < 2}
disabled={selectedProfiles.size < 2}
onClick={findCommonGames}
>
Finden
@ -465,12 +510,12 @@ export default function GameLibraryTab({ data }: { data: any }) {
<span className="gl-game-name">{g.name}</span>
<div className="gl-game-owners">
{g.owners.map(o => {
const u = getUser(o.steamId);
return u ? (
const profile = profiles.find(p => p.platforms.steam?.steamId === o.steamId);
return profile ? (
<img
key={o.steamId}
className="gl-game-owner-avatar"
src={u.avatarUrl}
src={profile.avatarUrl}
alt={o.personaName}
title={o.personaName}
/>
@ -491,42 +536,57 @@ export default function GameLibraryTab({ data }: { data: any }) {
</>
)}
{/* ── User mode ── */}
{/* ── User mode (profile detail) ── */}
{mode === 'user' && (() => {
const user = selectedUser ? getUser(selectedUser) : null;
const profile = selectedProfile ? getProfile(selectedProfile) : null;
return (
<>
<div className="gl-detail-header">
<button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck
</button>
{user && (
{profile && (
<>
<img className="gl-detail-avatar" src={user.avatarUrl} alt={user.personaName} />
<img className="gl-detail-avatar" src={profile.avatarUrl} alt={profile.displayName} />
<div className="gl-detail-info">
<div className="gl-detail-name">
{user.personaName}
<span className="gl-game-count">{user.gameCount} Spiele</span>
{profile.displayName}
<span className="gl-game-count">{profile.totalGames} Spiele</span>
</div>
<div className="gl-detail-sub">
Aktualisiert: {formatDate(user.lastUpdated)}
{profile.platforms.steam && (
<span className="gl-platform-badge steam" style={{ marginRight: 4 }}>
Steam
</span>
)}
{profile.platforms.gog ? (
<span className="gl-platform-badge gog">
GOG
</span>
) : (
<button className="gl-link-gog-btn" onClick={connectGog}>
🟣 GOG verknuepfen
</button>
)}
</div>
</div>
<button
className="gl-refresh-btn"
onClick={() => refreshUser(user.steamId)}
onClick={() => refreshProfile(profile.id)}
title="Aktualisieren"
>
&#x21bb;
</button>
<button
className={`gl-enrich-btn ${enriching === selectedUser ? 'enriching' : ''}`}
onClick={() => enrichUser(selectedUser!)}
disabled={enriching === selectedUser}
title={enriching === selectedUser ? 'IGDB-Daten werden geladen...' : 'Mit IGDB-Daten anreichern (erneut)'}
>
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
</button>
{profile.platforms.steam && (
<button
className={`gl-enrich-btn ${enriching === selectedProfile ? 'enriching' : ''}`}
onClick={() => enrichProfile(selectedProfile!)}
disabled={enriching === selectedProfile}
title={enriching === selectedProfile ? 'IGDB-Daten werden geladen...' : 'Mit IGDB-Daten anreichern (erneut)'}
>
{enriching === selectedProfile ? '\u23F3' : '\uD83C\uDF10'} IGDB
</button>
)}
</>
)}
</div>
@ -577,14 +637,20 @@ export default function GameLibraryTab({ data }: { data: any }) {
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
) : (
<div className="gl-game-list">
{filteredGames.map(g => (
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{filteredGames.map((g, idx) => (
<div key={g.appid ?? g.gogId ?? idx} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{/* Platform indicator */}
<span className={`gl-game-platform-icon ${g.platform || 'steam'}`}>
{g.platform === 'gog' ? 'G' : 'S'}
</span>
{/* Cover/Icon */}
<div className="gl-game-visual">
{g.igdb?.coverUrl ? (
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
) : g.img_icon_url ? (
) : g.img_icon_url && g.appid ? (
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
) : g.image ? (
<img className="gl-game-icon" src={g.image} alt="" />
) : (
<div className="gl-game-icon" />
)}
@ -621,10 +687,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
{/* ── Common mode ── */}
{mode === 'common' && (() => {
const selected = Array.from(selectedUsers)
.map(id => getUser(id))
.filter(Boolean) as UserSummary[];
const names = selected.map(u => u.personaName).join(', ');
const selected = Array.from(selectedProfiles)
.map(id => getProfile(id))
.filter(Boolean) as ProfileSummary[];
const names = selected.map(p => p.displayName).join(', ');
return (
<>
<div className="gl-detail-header">
@ -632,8 +698,8 @@ export default function GameLibraryTab({ data }: { data: any }) {
&#x2190; Zurueck
</button>
<div className="gl-detail-avatars">
{selected.map(u => (
<img key={u.steamId} src={u.avatarUrl} alt={u.personaName} />
{selected.map(p => (
<img key={p.id} src={p.avatarUrl} alt={p.displayName} />
))}
</div>
<div className="gl-detail-info">