gaming-hub/web/src/plugins/game-library/GameLibraryTab.tsx

895 lines
35 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useCallback } from 'react';
import './game-library.css';
/*
TYPES
*/
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 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;
platform: 'steam' | 'gog';
appid?: number;
gogId?: number;
playtime_forever?: number;
img_icon_url?: string;
image?: string;
igdb?: IgdbData;
}
interface CommonGame {
appid: number;
name: string;
img_icon_url: string;
igdb?: IgdbData;
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>;
}
interface SearchResult {
appid: number;
name: string;
img_icon_url: string;
owners: Array<{ steamId: string; personaName: string }>;
}
/*
HELPERS
*/
function gameIconUrl(appid: number, hash: string): string {
return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`;
}
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;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
/*
COMPONENT
*/
export default function GameLibraryTab({ data }: { data: any }) {
// ── State ──
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
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);
const [loading, setLoading] = useState(false);
const [enriching, setEnriching] = useState<string | null>(null);
const [activeGenres, setActiveGenres] = useState<Set<string>>(new Set());
const [sortBy, setSortBy] = useState<'playtime' | 'rating' | 'name'>('playtime');
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const filterInputRef = useRef<HTMLInputElement>(null);
const [filterQuery, setFilterQuery] = useState('');
// ── SSE data sync ──
useEffect(() => {
if (data?.profiles) setProfiles(data.profiles);
}, [data]);
// ── Refetch profiles ──
const fetchProfiles = useCallback(async () => {
try {
const resp = await fetch('/api/game-library/profiles');
if (resp.ok) {
const d = await resp.json();
setProfiles(d.profiles || []);
}
} catch { /* silent */ }
}, []);
// ── Steam login ──
const connectSteam = useCallback(() => {
const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600');
const interval = setInterval(() => {
if (w && w.closed) {
clearInterval(interval);
setTimeout(fetchProfiles, 1000);
}
}, 500);
}, [fetchProfiles]);
// ── GOG login ──
const isElectron = navigator.userAgent.includes('GamingHubDesktop');
// Code-paste dialog state (browser fallback only)
const [gogDialogOpen, setGogDialogOpen] = useState(false);
const [gogCode, setGogCode] = useState('');
const [gogStatus, setGogStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [gogStatusMsg, setGogStatusMsg] = useState('');
const connectGog = useCallback(() => {
const linkParam = selectedProfile ? `?linkTo=${selectedProfile}` : '';
if (isElectron) {
// Electron: Popup opens → GOG auth → Electron intercepts redirect → done
const w = window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=700');
const interval = setInterval(() => {
if (w && w.closed) {
clearInterval(interval);
setTimeout(fetchProfiles, 1000);
}
}, 500);
} else {
// Browser: Open GOG auth in popup, show code-paste dialog
window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=700');
setGogCode('');
setGogStatus('idle');
setGogStatusMsg('');
setGogDialogOpen(true);
}
}, [fetchProfiles, selectedProfile, isElectron]);
const submitGogCode = useCallback(async () => {
let code = gogCode.trim();
// Accept full URL or just the code
const codeMatch = code.match(/[?&]code=([^&]+)/);
if (codeMatch) code = codeMatch[1];
if (!code) return;
setGogStatus('loading');
setGogStatusMsg('Verbinde mit GOG...');
try {
const resp = await fetch('/api/game-library/gog/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, linkTo: selectedProfile || '' }),
});
const result = await resp.json();
if (resp.ok && result.ok) {
setGogStatus('success');
setGogStatusMsg(`${result.profileName}: ${result.gameCount} Spiele geladen!`);
fetchProfiles();
setTimeout(() => setGogDialogOpen(false), 2000);
} else {
setGogStatus('error');
setGogStatusMsg(result.error || 'Unbekannter Fehler');
}
} catch {
setGogStatus('error');
setGogStatusMsg('Verbindung fehlgeschlagen.');
}
}, [gogCode, selectedProfile, fetchProfiles]);
// ── Listen for GOG-connected event from Electron main process ──
useEffect(() => {
const onGogConnected = () => fetchProfiles();
window.addEventListener('gog-connected', onGogConnected);
return () => window.removeEventListener('gog-connected', onGogConnected);
}, [fetchProfiles]);
// ── Refetch on window focus (after login redirect) ──
useEffect(() => {
const onFocus = () => fetchProfiles();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [fetchProfiles]);
// ── View profile library ──
const viewProfile = useCallback(async (profileId: string) => {
setMode('user');
setSelectedProfile(profileId);
setUserGames(null);
setFilterQuery('');
setLoading(true);
try {
const resp = await fetch(`/api/game-library/profile/${profileId}/games`);
if (resp.ok) {
const d = await resp.json();
const games: MergedGame[] = d.games || d;
setUserGames(games);
// Auto-enrich with IGDB if many games lack data
// 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 {
/* silent */
} finally {
setLoading(false);
}
}, [profiles]);
// ── 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 {
if (steamId) {
await fetch(`/api/game-library/user/${steamId}?refresh=true`);
}
await fetchProfiles();
if (mode === 'user' && selectedProfile === profileId) {
viewProfile(profileId);
}
} catch {
/* silent */
}
}, [fetchProfiles, mode, selectedProfile, viewProfile, profiles]);
// ── 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 profile's game data to get IGDB info
if (mode === 'user' && selectedProfile === profileId) {
viewProfile(profileId);
}
}
} catch { /* silent */ }
finally { setEnriching(null); }
}, [mode, selectedProfile, viewProfile, profiles]);
// ── Toggle profile selection for common games ──
const toggleCommonProfile = useCallback((profileId: string) => {
setSelectedProfiles(prev => {
const next = new Set(prev);
if (next.has(profileId)) next.delete(profileId);
else next.add(profileId);
return next;
});
}, []);
// ── Find common games ──
const findCommonGames = useCallback(async () => {
if (selectedProfiles.size < 2) return;
setMode('common');
setCommonGames(null);
setLoading(true);
try {
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();
setCommonGames(d.games || d);
}
} catch {
/* silent */
} finally {
setLoading(false);
}
}, [selectedProfiles]);
// ── Search (debounced) ──
const handleSearch = useCallback((value: string) => {
setSearchQuery(value);
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (value.length < 2) {
setSearchResults(null);
return;
}
searchTimerRef.current = setTimeout(async () => {
try {
const resp = await fetch(`/api/game-library/search?q=${encodeURIComponent(value)}`);
if (resp.ok) {
const d = await resp.json();
setSearchResults(d.results || d);
}
} catch {
/* silent */
}
}, 300);
}, []);
// ── Genre toggle ──
const toggleGenre = useCallback((genre: string) => {
setActiveGenres(prev => {
const next = new Set(prev);
if (next.has(genre)) next.delete(genre);
else next.add(genre);
return next;
});
}, []);
// ── Back to overview ──
const goBack = useCallback(() => {
setMode('overview');
setSelectedProfile(null);
setUserGames(null);
setCommonGames(null);
setFilterQuery('');
setActiveGenres(new Set());
setSortBy('playtime');
}, []);
// ── Resolve profile by id ──
const getProfile = useCallback(
(profileId: string) => profiles.find(p => p.id === profileId),
[profiles],
);
// ── Sort helper ──
const getPlaytime = useCallback((g: any): number => {
if (typeof g.playtime_forever === 'number') return g.playtime_forever;
if (Array.isArray(g.owners)) return Math.max(...g.owners.map((o: any) => o.playtime_forever || 0));
return 0;
}, []);
const sortGames = useCallback(<T extends { name: string; igdb?: IgdbData }>(games: T[]): T[] => {
return [...games].sort((a, b) => {
if (sortBy === 'rating') {
const ra = a.igdb?.rating ?? -1;
const rb = b.igdb?.rating ?? -1;
return rb - ra;
}
if (sortBy === 'name') return a.name.localeCompare(b.name);
return getPlaytime(b) - getPlaytime(a);
});
}, [sortBy, getPlaytime]);
// ── Genre filter helper ──
const matchesGenres = useCallback((g: { igdb?: IgdbData }): boolean => {
if (activeGenres.size === 0) return true;
if (!g.igdb?.genres?.length) return false;
return g.igdb.genres.some(genre => activeGenres.has(genre));
}, [activeGenres]);
// ── Extract unique genres from game list ──
const extractGenres = useCallback((games: Array<{ igdb?: IgdbData }>): string[] => {
const counts = new Map<string, number>();
for (const g of games) {
if (g.igdb?.genres) {
for (const genre of g.igdb.genres) {
counts.set(genre, (counts.get(genre) || 0) + 1);
}
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([g]) => g);
}, []);
// ── Filtered user games ──
const filteredGames = userGames
? sortGames(
userGames
.filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase()))
.filter(matchesGenres)
)
: null;
// ── Available genres for current view ──
const availableGenres = userGames ? extractGenres(userGames) : [];
const availableCommonGenres = commonGames ? extractGenres(commonGames) : [];
// ── Filtered common games ──
const filteredCommonGames = commonGames
? sortGames(commonGames.filter(matchesGenres))
: null;
/*
RENDER
*/
return (
<div className="gl-container">
{/* ── Login Bar ── */}
<div className="gl-login-bar">
<button className="gl-connect-btn gl-steam-btn" onClick={connectSteam}>
🎮 Steam verbinden
</button>
<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={p.id}
className={`gl-profile-chip${selectedProfile === p.id ? ' selected' : ''}`}
onClick={() => viewProfile(p.id)}
>
<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>
)}
{/* ── Overview mode ── */}
{mode === 'overview' && (
<>
{profiles.length === 0 ? (
<div className="gl-empty">
<div className="gl-empty-icon">&#x1F3AE;</div>
<h3>Keine Konten verbunden</h3>
<p>
Klicke oben auf &ldquo;Steam verbinden&rdquo; oder &ldquo;GOG verbinden&rdquo;, um deine
Spielebibliothek hinzuzufuegen.
</p>
</div>
) : (
<>
{/* Profile cards */}
<p className="gl-section-title">Verbundene Spieler</p>
<div className="gl-users-grid">
{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(p.lastUpdated)}
</span>
</div>
))}
</div>
{/* Common games finder */}
{profiles.length >= 2 && (
<div className="gl-common-finder">
<h3>Gemeinsame Spiele finden</h3>
<div className="gl-common-users">
{profiles.map(p => (
<label
key={p.id}
className={`gl-common-check${selectedProfiles.has(p.id) ? ' checked' : ''}`}
>
<input
type="checkbox"
checked={selectedProfiles.has(p.id)}
onChange={() => toggleCommonProfile(p.id)}
/>
<img
className="gl-common-check-avatar"
src={p.avatarUrl}
alt={p.displayName}
/>
{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={selectedProfiles.size < 2}
onClick={findCommonGames}
>
Finden
</button>
</div>
)}
{/* Search */}
<div className="gl-search">
<input
className="gl-search-input"
type="text"
placeholder="Spiel suchen..."
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</div>
{/* Search results */}
{searchResults && searchResults.length > 0 && (
<>
<p className="gl-search-results-title">
{searchResults.length} Ergebnis{searchResults.length !== 1 ? 'se' : ''}
</p>
<div className="gl-game-list">
{searchResults.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>
<div className="gl-game-owners">
{g.owners.map(o => {
const profile = profiles.find(p => p.platforms.steam?.steamId === o.steamId);
return profile ? (
<img
key={o.steamId}
className="gl-game-owner-avatar"
src={profile.avatarUrl}
alt={o.personaName}
title={o.personaName}
/>
) : null;
})}
</div>
</div>
))}
</div>
</>
)}
{searchResults && searchResults.length === 0 && (
<p className="gl-search-results-title">Keine Ergebnisse gefunden.</p>
)}
</>
)}
</>
)}
{/* ── User mode (profile detail) ── */}
{mode === 'user' && (() => {
const profile = selectedProfile ? getProfile(selectedProfile) : null;
return (
<>
<div className="gl-detail-header">
<button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck
</button>
{profile && (
<>
<img className="gl-detail-avatar" src={profile.avatarUrl} alt={profile.displayName} />
<div className="gl-detail-info">
<div className="gl-detail-name">
{profile.displayName}
<span className="gl-game-count">{profile.totalGames} Spiele</span>
</div>
<div className="gl-detail-sub">
{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={() => refreshProfile(profile.id)}
title="Aktualisieren"
>
&#x21bb;
</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>
{loading ? (
<div className="gl-loading">Bibliothek wird geladen...</div>
) : filteredGames ? (
<>
<div className="gl-filter-bar">
<input
ref={filterInputRef}
className="gl-search-input"
type="text"
placeholder="Bibliothek durchsuchen..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
/>
<select
className="gl-sort-select"
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
>
<option value="playtime">Spielzeit</option>
<option value="rating">Bewertung</option>
<option value="name">Name</option>
</select>
</div>
{availableGenres.length > 0 && (
<div className="gl-genre-filters">
{activeGenres.size > 0 && (
<button className="gl-genre-chip active clear" onClick={() => setActiveGenres(new Set())}>
Alle
</button>
)}
{availableGenres.map(genre => (
<button
key={genre}
className={`gl-genre-chip${activeGenres.has(genre) ? ' active' : ''}`}
onClick={() => toggleGenre(genre)}
>
{genre}
</button>
))}
</div>
)}
<p className="gl-filter-count">{filteredGames.length} Spiele</p>
{filteredGames.length === 0 ? (
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
) : (
<div className="gl-game-list">
{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.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" />
)}
</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>
)}
</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>
)}
</>
) : null}
</>
);
})()}
{/* ── Common mode ── */}
{mode === 'common' && (() => {
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">
<button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck
</button>
<div className="gl-detail-avatars">
{selected.map(p => (
<img key={p.id} src={p.avatarUrl} alt={p.displayName} />
))}
</div>
<div className="gl-detail-info">
<div className="gl-detail-name">Gemeinsame Spiele</div>
<div className="gl-detail-sub">von {names}</div>
</div>
</div>
{loading ? (
<div className="gl-loading">Gemeinsame Spiele werden gesucht...</div>
) : commonGames ? (
commonGames.length === 0 ? (
<div className="gl-empty">
<div className="gl-empty-icon">&#x1F614;</div>
<h3>Keine gemeinsamen Spiele</h3>
<p>Die ausgewaehlten Spieler besitzen leider keine gemeinsamen Spiele.</p>
</div>
) : (
<>
<div className="gl-filter-bar">
<select
className="gl-sort-select"
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
>
<option value="playtime">Spielzeit</option>
<option value="rating">Bewertung</option>
<option value="name">Name</option>
</select>
</div>
{availableCommonGenres.length > 0 && (
<div className="gl-genre-filters">
{activeGenres.size > 0 && (
<button className="gl-genre-chip active clear" onClick={() => setActiveGenres(new Set())}>
Alle
</button>
)}
{availableCommonGenres.map(genre => (
<button
key={genre}
className={`gl-genre-chip${activeGenres.has(genre) ? ' active' : ''}`}
onClick={() => toggleGenre(genre)}
>
{genre}
</button>
))}
</div>
)}
<p className="gl-section-title">
{filteredCommonGames!.length} gemeinsame{filteredCommonGames!.length !== 1 ? ' Spiele' : 's Spiel'}
{activeGenres.size > 0 ? ` (von ${commonGames.length})` : ''}
</p>
<div className="gl-game-list">
{filteredCommonGames!.map(g => (
<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>
<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>
)}
</div>
<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>
)}
<div className="gl-common-playtimes">
{g.owners.map(o => (
<span key={o.steamId} className="gl-common-pt">
{o.personaName}: {formatPlaytime(o.playtime_forever)}
</span>
))}
</div>
</div>
</div>
))}
</div>
</>
)
) : null}
</>
);
})()}
{/* ── GOG Code Dialog (browser fallback only) ── */}
{gogDialogOpen && (
<div className="gl-dialog-overlay" onClick={() => setGogDialogOpen(false)}>
<div className="gl-dialog" onClick={e => e.stopPropagation()}>
<h3>🟣 GOG verbinden</h3>
<p className="gl-dialog-hint">
Nach dem GOG-Login wirst du auf eine Seite weitergeleitet.
Kopiere die <strong>komplette URL</strong> aus der Adressleiste und füge sie hier ein:
</p>
<input
className="gl-dialog-input"
type="text"
placeholder="https://embed.gog.com/on_login_success?code=..."
value={gogCode}
onChange={e => setGogCode(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submitGogCode(); }}
disabled={gogStatus === 'loading' || gogStatus === 'success'}
autoFocus
/>
{gogStatusMsg && (
<p className={`gl-dialog-status ${gogStatus}`}>{gogStatusMsg}</p>
)}
<div className="gl-dialog-actions">
<button onClick={() => setGogDialogOpen(false)} className="gl-dialog-cancel">Abbrechen</button>
<button
onClick={submitGogCode}
className="gl-dialog-submit"
disabled={!gogCode.trim() || gogStatus === 'loading' || gogStatus === 'success'}
>
{gogStatus === 'loading' ? 'Verbinde...' : 'Verbinden'}
</button>
</div>
</div>
</div>
)}
</div>
);
}