"use client" import { useEffect, useRef, useState } from "react" import Image from "next/image" import Link from "next/link" import { Loader2, SearchIcon, ExternalLinkIcon, TrashIcon, Gamepad2Icon, RefreshCwIcon, } from "lucide-react" 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 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 setSyncing(true) try { const res = await fetch("/api/games/sync/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ gameIds: ids, mode: "selected" }), }) if (res.ok) { const data = await res.json() alert(data.message) setSelectedIds(new Set()) // Refresh games list const refreshRes = await fetch( `/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}` ) if (refreshRes.ok) { const json = await refreshRes.json() setGames(json.data) setTotal(json.total) } } } catch (error) { console.error("Bulk sync failed:", error) alert("Sync failed. Check console for details.") } finally { setSyncing(false) } } const handleSyncAll = async () => { if (!confirm("This will sync all Steam games. Continue?")) return setSyncing(true) try { const res = await fetch("/api/games/sync/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "all" }), }) if (res.ok) { const data = await res.json() alert(data.message) // Refresh games list const refreshRes = await fetch( `/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}` ) if (refreshRes.ok) { const json = await refreshRes.json() setGames(json.data) setTotal(json.total) } } } catch (error) { console.error("Sync all failed:", error) alert("Sync failed. Check console for details.") } finally { setSyncing(false) } } const isSearchChangeRef = useRef(false) const handleSearchChange = (value: string) => { setSearch(value) setOffset(0) isSearchChangeRef.current = true } const handlePrev = () => { setOffset((prev) => Math.max(0, prev - LIMIT)) isSearchChangeRef.current = false } 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
{/* Floating action bar */} {selectedIds.size > 0 && (
{selectedIds.size} game{selectedIds.size !== 1 ? "s" : ""} selected
)} {/* Pagination */} {games.length > 0 && (

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

)}
) }