"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) setSyncCompleted(false) setSyncProgress({ isRunning: true, current: 0, total: gamesToSync.length, currentGame: "Preparing sync...", synced: 0, failed: 0, results: new Map(), }) try { // Use the bulk sync endpoint with selected game IDs const res = await fetch("/api/games/sync/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "selected", gameIds: gamesToSync.map((g) => g.id), }), }) if (!res.ok) { const errorData = await res.json().catch(() => null) throw new Error(errorData?.error || `Sync failed: HTTP ${res.status}`) } const data = await res.json() setSyncProgress((prev) => ({ ...prev, isRunning: false, synced: data.synced || 0, failed: data.failed || 0, currentGame: null, })) setSyncCompleted(true) setSelectedIds(new Set()) } catch (error) { console.error("Sync selected failed:", error) alert(`Sync failed: ${error instanceof Error ? error.message : String(error)}`) setSyncProgress((prev) => ({ ...prev, isRunning: false, currentGame: null, })) } finally { setSyncing(false) } } const handleSyncAll = async () => { if (!confirm("This will sync all Steam games. Continue?")) return setSyncing(true) setSyncCompleted(false) setSyncProgress({ isRunning: true, current: 0, total: 0, currentGame: "Preparing sync...", synced: 0, failed: 0, results: new Map(), }) try { // Use the bulk sync endpoint (processes in parallel) const res = await fetch("/api/games/sync/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "all" }), }) if (!res.ok) { const errorData = await res.json().catch(() => null) throw new Error(errorData?.error || `Sync failed: HTTP ${res.status}`) } const data = await res.json() setSyncProgress((prev) => ({ ...prev, isRunning: false, total: data.total || 0, synced: data.synced || 0, failed: data.failed || 0, currentGame: null, })) setSyncCompleted(true) } catch (error) { console.error("Sync all failed:", error) alert(`Sync failed: ${error instanceof Error ? error.message : String(error)}`) setSyncProgress((prev) => ({ ...prev, isRunning: false, currentGame: null, })) } finally { setSyncing(false) } } const closeSyncOverlay = () => { setSyncCompleted(false) setSyncProgress({ isRunning: false, current: 0, total: 0, currentGame: null, synced: 0, failed: 0, results: new Map(), }) } // Close sync overlay on Escape key useEffect(() => { if (!syncCompleted) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") closeSyncOverlay() } document.addEventListener("keydown", handleKeyDown) return () => document.removeEventListener("keydown", handleKeyDown) }, [syncCompleted]) 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.total > 0 ? `${syncProgress.current} of ${syncProgress.total} games` : "Preparing to sync..." }

{/* Progress bar */}
Progress {syncProgress.total > 0 ? Math.round((syncProgress.current / syncProgress.total) * 100) : 0}%
{/* 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}

)}
) }