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

589 lines
22 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useCallback } from 'react';
import './game-library.css';
/*
TYPES
*/
interface UserSummary {
steamId: string;
personaName: string;
avatarUrl: string;
gameCount: number;
lastUpdated: string;
}
interface IgdbData {
igdbId: number;
name: string;
coverUrl: string | null;
genres: string[];
platforms: string[];
rating: number | null;
firstReleaseDate: string | null;
summary: string | null;
igdbUrl: string | null;
}
interface SteamGame {
appid: number;
name: string;
playtime_forever: number;
img_icon_url: string;
igdb?: IgdbData;
}
interface CommonGame {
appid: number;
name: string;
img_icon_url: string;
igdb?: IgdbData;
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>;
}
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): string {
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 [users, setUsers] = useState<UserSummary[]>([]);
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 [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 searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const filterInputRef = useRef<HTMLInputElement>(null);
const [filterQuery, setFilterQuery] = useState('');
// ── SSE data sync ──
useEffect(() => {
if (data?.users) setUsers(data.users);
}, [data]);
// ── Refetch users ──
const fetchUsers = useCallback(async () => {
try {
const resp = await fetch('/api/game-library/users');
if (resp.ok) {
const d = await resp.json();
setUsers(d.users || []);
}
} 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(fetchUsers, 1000);
}
}, 500);
}, [fetchUsers]);
// ── Refetch on window focus (after login redirect) ──
useEffect(() => {
const onFocus = () => fetchUsers();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [fetchUsers]);
// ── View user library ──
const viewUser = useCallback(async (steamId: string) => {
setMode('user');
setSelectedUser(steamId);
setUserGames(null);
setFilterQuery('');
setLoading(true);
try {
const resp = await fetch(`/api/game-library/user/${steamId}`);
if (resp.ok) {
const d = await resp.json();
setUserGames(d.games || d);
}
} catch {
/* silent */
} finally {
setLoading(false);
}
}, []);
// ── Refresh single user ──
const refreshUser = useCallback(async (steamId: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
try {
await fetch(`/api/game-library/user/${steamId}?refresh=true`);
await fetchUsers();
if (mode === 'user' && selectedUser === steamId) {
viewUser(steamId);
}
} catch {
/* silent */
}
}, [fetchUsers, mode, selectedUser, viewUser]);
// ── Enrich user library with IGDB data ──
const enrichUser = useCallback(async (steamId: string) => {
setEnriching(steamId);
try {
const resp = await fetch(`/api/game-library/igdb/enrich/${steamId}`);
if (resp.ok) {
// Reload user's game data to get IGDB info
if (mode === 'user' && selectedUser === steamId) {
viewUser(steamId);
}
}
} catch { /* silent */ }
finally { setEnriching(null); }
}, [mode, selectedUser, viewUser]);
// ── Toggle user selection for common games ──
const toggleCommonUser = useCallback((steamId: string) => {
setSelectedUsers(prev => {
const next = new Set(prev);
if (next.has(steamId)) next.delete(steamId);
else next.add(steamId);
return next;
});
}, []);
// ── Find common games ──
const findCommonGames = useCallback(async () => {
if (selectedUsers.size < 2) return;
setMode('common');
setCommonGames(null);
setLoading(true);
try {
const ids = Array.from(selectedUsers).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);
}
}, [selectedUsers]);
// ── 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);
}, []);
// ── Back to overview ──
const goBack = useCallback(() => {
setMode('overview');
setSelectedUser(null);
setUserGames(null);
setCommonGames(null);
setFilterQuery('');
}, []);
// ── Resolve user by steamId ──
const getUser = useCallback(
(steamId: string) => users.find(u => u.steamId === steamId),
[users],
);
// ── Filtered user games ──
const filteredGames = userGames
? userGames
.filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase()))
.sort((a, b) => b.playtime_forever - a.playtime_forever)
: null;
/*
RENDER
*/
return (
<div className="gl-container">
{/* ── Top bar ── */}
<div className="gl-topbar">
<button className="gl-connect-btn" onClick={connectSteam}>
Mit Steam verbinden
</button>
<div className="gl-user-chips">
{users.map(u => (
<div
key={u.steamId}
className={`gl-user-chip${selectedUser === u.steamId ? ' selected' : ''}`}
onClick={() => viewUser(u.steamId)}
>
<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>
</div>
))}
</div>
</div>
{/* ── Overview mode ── */}
{mode === 'overview' && (
<>
{users.length === 0 ? (
<div className="gl-empty">
<div className="gl-empty-icon">&#x1F3AE;</div>
<h3>Keine Steam-Konten verbunden</h3>
<p>
Klicke oben auf &ldquo;Mit Steam verbinden&rdquo;, um deine Spielebibliothek
hinzuzufuegen.
</p>
</div>
) : (
<>
{/* User 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>
<span className="gl-user-card-updated">
Aktualisiert: {formatDate(u.lastUpdated)}
</span>
</div>
))}
</div>
{/* Common games finder */}
{users.length >= 2 && (
<div className="gl-common-finder">
<h3>Gemeinsame Spiele finden</h3>
<div className="gl-common-users">
{users.map(u => (
<label
key={u.steamId}
className={`gl-common-check${selectedUsers.has(u.steamId) ? ' checked' : ''}`}
>
<input
type="checkbox"
checked={selectedUsers.has(u.steamId)}
onChange={() => toggleCommonUser(u.steamId)}
/>
<img
className="gl-common-check-avatar"
src={u.avatarUrl}
alt={u.personaName}
/>
{u.personaName}
</label>
))}
</div>
<button
className="gl-common-find-btn"
disabled={selectedUsers.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 u = getUser(o.steamId);
return u ? (
<img
key={o.steamId}
className="gl-game-owner-avatar"
src={u.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 ── */}
{mode === 'user' && (() => {
const user = selectedUser ? getUser(selectedUser) : null;
return (
<>
<div className="gl-detail-header">
<button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck
</button>
{user && (
<>
<img className="gl-detail-avatar" src={user.avatarUrl} alt={user.personaName} />
<div className="gl-detail-info">
<div className="gl-detail-name">
{user.personaName}
<span className="gl-game-count">{user.gameCount} Spiele</span>
</div>
<div className="gl-detail-sub">
Aktualisiert: {formatDate(user.lastUpdated)}
</div>
</div>
<button
className="gl-refresh-btn"
onClick={() => refreshUser(user.steamId)}
title="Aktualisieren"
>
&#x21bb;
</button>
<button
className="gl-enrich-btn"
onClick={() => enrichUser(selectedUser!)}
disabled={enriching === selectedUser}
title="Mit IGDB-Daten anreichern"
>
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
</button>
</>
)}
</div>
{loading ? (
<div className="gl-loading">Bibliothek wird geladen...</div>
) : filteredGames ? (
<>
<div className="gl-search">
<input
ref={filterInputRef}
className="gl-search-input"
type="text"
placeholder="Bibliothek durchsuchen..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
/>
</div>
{filteredGames.length === 0 ? (
<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' : ''}`}>
{/* Cover/Icon */}
<div className="gl-game-visual">
{g.igdb?.coverUrl ? (
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
) : g.img_icon_url ? (
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
) : (
<div className="gl-game-icon" />
)}
</div>
{/* Info */}
<div className="gl-game-info">
<span className="gl-game-name">{g.name}</span>
{g.igdb?.genres && g.igdb.genres.length > 0 && (
<div className="gl-game-genres">
{g.igdb.genres.slice(0, 3).map(genre => (
<span key={genre} className="gl-genre-tag">{genre}</span>
))}
</div>
)}
{g.igdb?.platforms && g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).length > 0 && (
<div className="gl-game-platforms">
Auch auf: {g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).slice(0, 3).map(p => (
<span key={p} className="gl-platform-tag">{p}</span>
))}
</div>
)}
</div>
{/* Right side: rating + playtime */}
<div className="gl-game-meta">
{g.igdb?.rating != null && (
<span className={`gl-game-rating ${g.igdb.rating >= 75 ? 'high' : g.igdb.rating >= 50 ? 'mid' : 'low'}`}>
{Math.round(g.igdb.rating)}
</span>
)}
<span className="gl-game-playtime">{formatPlaytime(g.playtime_forever)}</span>
</div>
</div>
))}
</div>
)}
</>
) : null}
</>
);
})()}
{/* ── 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(', ');
return (
<>
<div className="gl-detail-header">
<button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck
</button>
<div className="gl-detail-avatars">
{selected.map(u => (
<img key={u.steamId} src={u.avatarUrl} alt={u.personaName} />
))}
</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>
) : (
<>
<p className="gl-section-title">
{commonGames.length} gemeinsame{commonGames.length !== 1 ? ' Spiele' : 's Spiel'}
</p>
<div className="gl-game-list">
{commonGames.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>
<span className="gl-game-name">{g.name}</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>
</>
)
) : null}
</>
);
})()}
</div>
);
}