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:
parent
89e655482b
commit
ee5c29dd7b
4 changed files with 859 additions and 189 deletions
|
|
@ -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"
|
||||
>
|
||||
↻
|
||||
</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">🎮</div>
|
||||
<h3>Keine Steam-Konten verbunden</h3>
|
||||
<h3>Keine Konten verbunden</h3>
|
||||
<p>
|
||||
Klicke oben auf “Mit Steam verbinden”, um deine Spielebibliothek
|
||||
hinzuzufuegen.
|
||||
Klicke oben auf “Steam verbinden” oder “GOG verbinden”, 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}>
|
||||
← 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"
|
||||
>
|
||||
↻
|
||||
</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 }) {
|
|||
← 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">
|
||||
|
|
|
|||
|
|
@ -9,20 +9,16 @@
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
/* ── Login Bar ── */
|
||||
|
||||
.gl-topbar {
|
||||
.gl-login-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gl-connect-btn {
|
||||
background: #1b2838;
|
||||
color: #c7d5e0;
|
||||
border: 1px solid #2a475e;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
|
|
@ -31,71 +27,126 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gl-connect-btn:hover {
|
||||
.gl-steam-btn {
|
||||
background: #1b2838;
|
||||
border: 1px solid #2a475e;
|
||||
}
|
||||
|
||||
.gl-steam-btn:hover {
|
||||
background: #2a475e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── User chips (top bar) ── */
|
||||
.gl-gog-btn {
|
||||
background: #2c1a4e;
|
||||
border: 1px solid #4a2d7a;
|
||||
color: #c7b3e8;
|
||||
}
|
||||
|
||||
.gl-user-chips {
|
||||
.gl-gog-btn:hover {
|
||||
background: #3d2566;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Profile Chips ── */
|
||||
|
||||
.gl-profile-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gl-user-chip {
|
||||
.gl-profile-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px 10px 4px 4px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gl-user-chip:hover {
|
||||
border-color: var(--accent);
|
||||
.gl-profile-chip.selected {
|
||||
border-color: #e67e22;
|
||||
background: rgba(230,126,34,0.1);
|
||||
}
|
||||
|
||||
.gl-user-chip.selected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(230, 126, 34, 0.12);
|
||||
.gl-profile-chip:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.gl-user-chip-avatar {
|
||||
.gl-profile-chip-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gl-user-chip-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
.gl-profile-chip-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.gl-user-chip-count {
|
||||
.gl-profile-chip-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.gl-profile-chip-platforms {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-profile-chip-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
color: #667;
|
||||
}
|
||||
|
||||
.gl-user-chip-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
transition: color var(--transition);
|
||||
/* ── Platform Badges ── */
|
||||
|
||||
.gl-platform-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gl-user-chip-refresh:hover {
|
||||
color: var(--accent);
|
||||
.gl-platform-badge.steam {
|
||||
background: rgba(27,40,56,0.8);
|
||||
color: #66c0f4;
|
||||
border: 1px solid #2a475e;
|
||||
}
|
||||
|
||||
.gl-platform-badge.gog {
|
||||
background: rgba(44,26,78,0.8);
|
||||
color: #b388ff;
|
||||
border: 1px solid #4a2d7a;
|
||||
}
|
||||
|
||||
/* ── Game Platform Icon ── */
|
||||
|
||||
.gl-game-platform-icon {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-platform-icon.steam {
|
||||
background: rgba(27,40,56,0.6);
|
||||
color: #66c0f4;
|
||||
}
|
||||
|
||||
.gl-game-platform-icon.gog {
|
||||
background: rgba(44,26,78,0.6);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
/* ── User cards grid (overview) ── */
|
||||
|
|
@ -147,6 +198,14 @@
|
|||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ── Profile Cards ── */
|
||||
|
||||
.gl-profile-card-platforms {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Common games finder ── */
|
||||
|
||||
.gl-common-finder {
|
||||
|
|
@ -338,6 +397,10 @@
|
|||
.gl-detail-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.gl-refresh-btn {
|
||||
|
|
@ -354,6 +417,24 @@
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Link GOG Button ── */
|
||||
|
||||
.gl-link-gog-btn {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gl-link-gog-btn:hover {
|
||||
background: rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
|
||||
/* ── Loading ── */
|
||||
|
||||
.gl-loading {
|
||||
|
|
@ -652,8 +733,7 @@
|
|||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.gl-topbar {
|
||||
.gl-login-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue