"use client" import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import Image from "next/image" import Link from "next/link" import { Loader2, SearchIcon, ExternalLinkIcon, TrashIcon, Gamepad2Icon, RefreshCwIcon, CheckCircle2Icon, XCircleIcon, } from "lucide-react" interface SyncProgress { isRunning: boolean current: number total: number currentGame: string | null synced: number failed: number results: Map } interface Game { id: string steamAppId: number | null title: string description: string | null developer: string | null publisher: string | null genres: string[] | null headerImage: string | null capsuleImage: string | null storeUrl: string | null source: "steam" | "manual" | "gog" | "epic" lastSync: string | null syncStatus: string | null syncError: string | null createdAt: string updatedAt: string } interface GamesApiResponse { data: Game[] total: number limit: number offset: number } const LIMIT = 50 export function GamesClient() { const [games, setGames] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [search, setSearch] = useState("") const [offset, setOffset] = useState(0) const [deletingIds, setDeletingIds] = useState>(new Set()) const [resyncingIds, setResyncingIds] = useState>(new Set()) const [selectedIds, setSelectedIds] = useState>(new Set()) const [syncing, setSyncing] = useState(false) const [syncProgress, setSyncProgress] = useState({ isRunning: false, current: 0, total: 0, currentGame: null, synced: 0, failed: 0, results: new Map(), }) const [syncCompleted, setSyncCompleted] = useState(false) const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(new Set(games.map((g) => g.id))) } else { setSelectedIds(new Set()) } } const handleSelectOne = (id: string, checked: boolean) => { setSelectedIds((prev) => { const next = new Set(prev) if (checked) next.add(id) else next.delete(id) return next }) } const handleSyncSelected = async () => { const ids = Array.from(selectedIds) if (ids.length === 0) return const gamesToSync = games.filter((g) => ids.includes(g.id) && g.steamAppId) if (gamesToSync.length === 0) { alert("No Steam games selected to sync") return } setSyncing(true) setSyncProgress({ isRunning: true, current: 0, total: gamesToSync.length, currentGame: null, synced: 0, failed: 0, results: new Map(), }) let synced = 0 let failed = 0 const results = new Map() for (let i = 0; i < gamesToSync.length; i++) { const game = gamesToSync[i] setSyncProgress((prev) => ({ ...prev, current: i + 1, currentGame: game.title, })) try { const res = await fetch(`/api/games/${game.id}/sync`, { method: "POST" }) if (res.ok) { const data = await res.json() if (data.status === "synced") { synced++ results.set(game.id, { success: true }) } else { failed++ results.set(game.id, { success: false, error: data.error }) } } else { failed++ results.set(game.id, { success: false, error: `HTTP ${res.status}` }) } } catch (error) { failed++ results.set(game.id, { success: false, error: String(error) }) } setSyncProgress((prev) => ({ ...prev, synced, failed, results: new Map(results), })) // Update the game in the list immediately const result = results.get(game.id) if (result?.success) { setGames((prev) => prev.map((g) => g.id === game.id ? { ...g, syncStatus: "synced", lastSync: new Date().toISOString(), syncError: null } : g ) ) } else { setGames((prev) => prev.map((g) => g.id === game.id ? { ...g, syncStatus: "failed", syncError: result?.error || "Sync failed" } : g ) ) } // Rate limit: 500ms between syncs (skip on last) if (i < gamesToSync.length - 1) { await new Promise((resolve) => setTimeout(resolve, 500)) } } setSyncProgress((prev) => ({ ...prev, isRunning: false, currentGame: null, })) setSyncing(false) setSelectedIds(new Set()) setSyncCompleted(true) } const handleSyncAll = async () => { if (!confirm("This will sync all Steam games. Continue?")) return // Fetch all Steam games setSyncing(true) try { const res = await fetch("/api/games?limit=1000&filter_source=steam") if (!res.ok) { alert("Failed to fetch games list") setSyncing(false) return } const data = await res.json() const allSteamGames = data.data.filter((g: Game) => g.steamAppId) if (allSteamGames.length === 0) { alert("No Steam games to sync") setSyncing(false) return } setSyncProgress({ isRunning: true, current: 0, total: allSteamGames.length, currentGame: null, synced: 0, failed: 0, results: new Map(), }) let synced = 0 let failed = 0 const results = new Map() for (let i = 0; i < allSteamGames.length; i++) { const game = allSteamGames[i] setSyncProgress((prev) => ({ ...prev, current: i + 1, currentGame: game.title, })) try { const syncRes = await fetch(`/api/games/${game.id}/sync`, { method: "POST" }) if (syncRes.ok) { const syncData = await syncRes.json() if (syncData.status === "synced") { synced++ results.set(game.id, { success: true }) } else { failed++ results.set(game.id, { success: false, error: syncData.error }) } } else { failed++ results.set(game.id, { success: false, error: `HTTP ${syncRes.status}` }) } } catch (error) { failed++ results.set(game.id, { success: false, error: String(error) }) } setSyncProgress((prev) => ({ ...prev, synced, failed, results: new Map(results), })) // Update the game in the list if it's currently visible const result = results.get(game.id) setGames((prev) => prev.map((g) => g.id === game.id ? result?.success ? { ...g, syncStatus: "synced", lastSync: new Date().toISOString(), syncError: null } : { ...g, syncStatus: "failed", syncError: result?.error || "Sync failed" } : g ) ) // Rate limit: 500ms between syncs (skip on last) if (i < allSteamGames.length - 1) { await new Promise((resolve) => setTimeout(resolve, 500)) } } setSyncProgress((prev) => ({ ...prev, isRunning: false, currentGame: null, })) setSyncCompleted(true) } catch (error) { console.error("Sync all failed:", error) alert("Sync failed. Check console for details.") } finally { setSyncing(false) } } const closeSyncOverlay = () => { setSyncCompleted(false) setSyncProgress({ isRunning: false, current: 0, total: 0, currentGame: null, synced: 0, failed: 0, results: new Map(), }) } const isSearchChangeRef = useRef(false) const handleSearchChange = (value: string) => { setSearch(value) setOffset(0) isSearchChangeRef.current = true setSelectedIds(new Set()) // Clear selection on search change } const handlePrev = () => { setOffset((prev) => Math.max(0, prev - LIMIT)) isSearchChangeRef.current = false setSelectedIds(new Set()) // Clear selection on page change } const handleNext = () => { setOffset((prev) => prev + LIMIT) isSearchChangeRef.current = false } useEffect(() => { const delay = isSearchChangeRef.current ? 300 : 0 isSearchChangeRef.current = false let cancelled = false const timer = setTimeout(async () => { setLoading(true) try { const res = await fetch( `/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}` ) if (res.ok && !cancelled) { const json = (await res.json()) as GamesApiResponse setGames(json.data) setTotal(json.total) } } catch { // ignore } finally { if (!cancelled) setLoading(false) } }, delay) return () => { cancelled = true clearTimeout(timer) } }, [search, offset]) const handleResync = async (game: Game) => { setResyncingIds((prev) => new Set(prev).add(game.id)) try { const res = await fetch(`/api/games/${game.id}/sync`, { method: "POST", }) if (res.ok) { setGames((prev) => prev.map((g) => g.id === game.id ? { ...g, syncStatus: "synced", lastSync: new Date().toISOString() } : g ) ) } } catch (error) { console.error("Resync failed:", error) } finally { setResyncingIds((prev) => { const next = new Set(prev) next.delete(game.id) return next }) } } const handleDelete = async (game: Game) => { if (!confirm(`Are you sure you want to delete "${game.title}"?`)) return setDeletingIds((prev) => new Set(prev).add(game.id)) try { const res = await fetch(`/api/games/${game.id}`, { method: "DELETE" }) if (res.ok) { setGames((prev) => prev.filter((g) => g.id !== game.id)) setTotal((prev) => Math.max(0, prev - 1)) } } finally { setDeletingIds((prev) => { const next = new Set(prev) next.delete(game.id) return next }) } } const hasMore = offset + games.length < total return (
{/* Search */}
handleSearchChange(e.target.value)} placeholder="Search games..." className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors" />
{/* Table */}
{loading ? ( ) : games.length === 0 ? ( ) : ( games.map((game) => ( )) )}
0} onChange={(e) => handleSelectAll(e.target.checked)} className="rounded border-border" /> Cover Title Developer Source Sync Actions
No games found.
handleSelectOne(game.id, e.target.checked)} className="rounded border-border" />
{game.capsuleImage || game.headerImage ? ( {game.title} ) : ( )}

{game.title}

{game.developer || "—"}

{game.source} {game.syncStatus === "synced" ? "Synced" : game.syncStatus === "failed" ? "Failed" : "Stale"}
View
{/* Sync Progress Overlay — portaled to body for guaranteed viewport coverage */} {(syncProgress.isRunning || syncCompleted) && createPortal(
{syncProgress.isRunning ? ( <>

Syncing Games

{syncProgress.current} of {syncProgress.total} games

{/* Progress bar */}
Progress {Math.round((syncProgress.current / syncProgress.total) * 100)}%
{/* Current game */} {syncProgress.currentGame && (

Currently syncing:

{syncProgress.currentGame}

)} ) : ( <> {/* Completed state header */}

Sync Complete

{syncProgress.synced} synced, {syncProgress.failed} failed

)} {/* Stats — shown in both running and completed states */}
{syncProgress.synced}

Synced

{syncProgress.failed}

Failed

{/* Results list — scrollable */} {syncProgress.results.size > 0 && (

{syncCompleted ? "All results:" : "Recent results:"}

{(syncCompleted ? Array.from(syncProgress.results.entries()) : Array.from(syncProgress.results.entries()).slice(-5).reverse() ).map(([gameId, result]) => { const game = games.find((g) => g.id === gameId) return (
{result.success ? ( ) : ( )} {game?.title || gameId} {!result.success && result.error && ( {result.error} )}
) })}
)}
, document.body )} {/* Floating action bar */} {selectedIds.size > 0 && !syncProgress.isRunning && (
{selectedIds.size} game{selectedIds.size !== 1 ? "s" : ""} selected
)} {/* Pagination */} {games.length > 0 && (

Showing {offset + 1}–{Math.min(offset + games.length, total)} of{" "} {total}

)}
) }