- Admin-Panel mit Login (gleiches Passwort wie Soundboard) zum Entfernen von Profilen inkl. aller verknuepften Daten - Disconnect-Buttons im Profil-Detail: Steam/GOG einzeln trennbar - IGDB persistenter File-Cache (ueberlebt Server-Neustarts) - SSE-Broadcast-Format korrigiert (buildProfileSummaries shared helper) - DELETE-Endpoints fuer Profil/Steam/GOG Trennung Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1087 lines
43 KiB
TypeScript
1087 lines
43 KiB
TypeScript
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('');
|
|
|
|
// ── Admin state ──
|
|
const [showAdmin, setShowAdmin] = useState(false);
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const [adminPwd, setAdminPwd] = useState('');
|
|
const [adminProfiles, setAdminProfiles] = useState<any[]>([]);
|
|
const [adminLoading, setAdminLoading] = useState(false);
|
|
const [adminError, setAdminError] = 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 */ }
|
|
}, []);
|
|
|
|
// ── Admin: check login status on mount ──
|
|
useEffect(() => {
|
|
fetch('/api/game-library/admin/status', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(d => setIsAdmin(d.admin === true))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// ── Admin: login ──
|
|
const adminLogin = useCallback(async () => {
|
|
setAdminError('');
|
|
try {
|
|
const resp = await fetch('/api/game-library/admin/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: adminPwd }),
|
|
credentials: 'include',
|
|
});
|
|
if (resp.ok) {
|
|
setIsAdmin(true);
|
|
setAdminPwd('');
|
|
} else {
|
|
const d = await resp.json();
|
|
setAdminError(d.error || 'Fehler');
|
|
}
|
|
} catch {
|
|
setAdminError('Verbindung fehlgeschlagen');
|
|
}
|
|
}, [adminPwd]);
|
|
|
|
// ── Admin: logout ──
|
|
const adminLogout = useCallback(async () => {
|
|
await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' });
|
|
setIsAdmin(false);
|
|
setShowAdmin(false);
|
|
}, []);
|
|
|
|
// ── Admin: load profiles ──
|
|
const loadAdminProfiles = useCallback(async () => {
|
|
setAdminLoading(true);
|
|
try {
|
|
const resp = await fetch('/api/game-library/admin/profiles', { credentials: 'include' });
|
|
if (resp.ok) {
|
|
const d = await resp.json();
|
|
setAdminProfiles(d.profiles || []);
|
|
}
|
|
} catch { /* silent */ }
|
|
finally { setAdminLoading(false); }
|
|
}, []);
|
|
|
|
// ── Admin: open panel ──
|
|
const openAdmin = useCallback(() => {
|
|
setShowAdmin(true);
|
|
if (isAdmin) loadAdminProfiles();
|
|
}, [isAdmin, loadAdminProfiles]);
|
|
|
|
// ── Admin: delete profile ──
|
|
const adminDeleteProfile = useCallback(async (profileId: string, displayName: string) => {
|
|
if (!confirm(`Profil "${displayName}" wirklich komplett loeschen? (Alle verknuepften Daten werden entfernt)`)) return;
|
|
try {
|
|
const resp = await fetch(`/api/game-library/admin/profile/${profileId}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
if (resp.ok) {
|
|
loadAdminProfiles();
|
|
fetchProfiles();
|
|
}
|
|
} catch { /* silent */ }
|
|
}, [loadAdminProfiles, fetchProfiles]);
|
|
|
|
// ── 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;
|
|
});
|
|
}, []);
|
|
|
|
// ── Disconnect platform ──
|
|
const disconnectPlatform = useCallback(async (profileId: string, platform: 'steam' | 'gog') => {
|
|
if (!confirm(`${platform === 'steam' ? 'Steam' : 'GOG'}-Verknuepfung wirklich trennen?`)) return;
|
|
try {
|
|
const resp = await fetch(`/api/game-library/profile/${profileId}/${platform}`, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
fetchProfiles();
|
|
// If we disconnected the last platform, go back to overview
|
|
const result = await resp.json();
|
|
if (result.ok) {
|
|
const p = profiles.find(pr => pr.id === profileId);
|
|
const otherPlatform = platform === 'steam' ? p?.platforms.gog : p?.platforms.steam;
|
|
if (!otherPlatform) goBackFn();
|
|
}
|
|
}
|
|
} catch { /* silent */ }
|
|
}, [fetchProfiles, profiles]);
|
|
|
|
// ── Delete profile ──
|
|
const deleteProfile = useCallback(async (profileId: string) => {
|
|
const p = profiles.find(pr => pr.id === profileId);
|
|
if (!confirm(`Profil "${p?.displayName}" wirklich komplett loeschen?`)) return;
|
|
try {
|
|
const resp = await fetch(`/api/game-library/profile/${profileId}`, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
fetchProfiles();
|
|
goBackFn();
|
|
}
|
|
} catch { /* silent */ }
|
|
}, [fetchProfiles, profiles]);
|
|
|
|
// ── Back to overview ──
|
|
const goBackFn = useCallback(() => {
|
|
setMode('overview');
|
|
setSelectedProfile(null);
|
|
setUserGames(null);
|
|
setCommonGames(null);
|
|
setFilterQuery('');
|
|
setActiveGenres(new Set());
|
|
setSortBy('playtime');
|
|
}, []);
|
|
const goBack = goBackFn;
|
|
|
|
// ── 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 className="gl-login-bar-spacer" />
|
|
<button className="gl-admin-btn" onClick={openAdmin} title="Admin Panel">
|
|
⚙️
|
|
</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">🎮</div>
|
|
<h3>Keine Konten verbunden</h3>
|
|
<p>
|
|
Klicke oben auf “Steam verbinden” oder “GOG verbinden”, 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}>
|
|
← 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-detail steam">
|
|
<span className="gl-platform-badge steam">Steam ✓</span>
|
|
<button
|
|
className="gl-disconnect-btn"
|
|
onClick={(e) => { e.stopPropagation(); disconnectPlatform(profile.id, 'steam'); }}
|
|
title="Steam trennen"
|
|
>✕</button>
|
|
</span>
|
|
)}
|
|
{profile.platforms.gog ? (
|
|
<span className="gl-platform-detail gog">
|
|
<span className="gl-platform-badge gog">GOG ✓</span>
|
|
<button
|
|
className="gl-disconnect-btn"
|
|
onClick={(e) => { e.stopPropagation(); disconnectPlatform(profile.id, 'gog'); }}
|
|
title="GOG trennen"
|
|
>✕</button>
|
|
</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"
|
|
>
|
|
↻
|
|
</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}>
|
|
← 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">😔</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}
|
|
</>
|
|
);
|
|
})()}
|
|
|
|
{/* ── Admin Panel ── */}
|
|
{showAdmin && (
|
|
<div className="gl-dialog-overlay" onClick={() => setShowAdmin(false)}>
|
|
<div className="gl-admin-panel" onClick={e => e.stopPropagation()}>
|
|
<div className="gl-admin-header">
|
|
<h3>⚙️ Game Library Admin</h3>
|
|
<button className="gl-admin-close" onClick={() => setShowAdmin(false)}>✕</button>
|
|
</div>
|
|
|
|
{!isAdmin ? (
|
|
<div className="gl-admin-login">
|
|
<p>Admin-Passwort eingeben:</p>
|
|
<div className="gl-admin-login-row">
|
|
<input
|
|
type="password"
|
|
className="gl-dialog-input"
|
|
placeholder="Passwort"
|
|
value={adminPwd}
|
|
onChange={e => setAdminPwd(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
|
|
autoFocus
|
|
/>
|
|
<button className="gl-admin-login-btn" onClick={adminLogin}>Login</button>
|
|
</div>
|
|
{adminError && <p className="gl-dialog-status error">{adminError}</p>}
|
|
</div>
|
|
) : (
|
|
<div className="gl-admin-content">
|
|
<div className="gl-admin-toolbar">
|
|
<span className="gl-admin-status-text">✅ Eingeloggt als Admin</span>
|
|
<button className="gl-admin-refresh-btn" onClick={loadAdminProfiles}>↻ Aktualisieren</button>
|
|
<button className="gl-admin-logout-btn" onClick={adminLogout}>Logout</button>
|
|
</div>
|
|
|
|
{adminLoading ? (
|
|
<div className="gl-loading">Lade Profile...</div>
|
|
) : adminProfiles.length === 0 ? (
|
|
<p className="gl-search-results-title">Keine Profile vorhanden.</p>
|
|
) : (
|
|
<div className="gl-admin-list">
|
|
{adminProfiles.map((p: any) => (
|
|
<div key={p.id} className="gl-admin-item">
|
|
<img className="gl-admin-item-avatar" src={p.avatarUrl} alt={p.displayName} />
|
|
<div className="gl-admin-item-info">
|
|
<span className="gl-admin-item-name">{p.displayName}</span>
|
|
<span className="gl-admin-item-details">
|
|
{p.steamName && <span className="gl-platform-badge steam">Steam: {p.steamGames}</span>}
|
|
{p.gogName && <span className="gl-platform-badge gog">GOG: {p.gogGames}</span>}
|
|
<span className="gl-admin-item-total">{p.totalGames} Spiele</span>
|
|
</span>
|
|
</div>
|
|
<button
|
|
className="gl-admin-delete-btn"
|
|
onClick={() => adminDeleteProfile(p.id, p.displayName)}
|
|
title="Profil loeschen"
|
|
>
|
|
🗑️ Entfernen
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
);
|
|
}
|