Files
deckyvault/app/(manage)/manage/games/games-client.tsx
T

774 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, { success: boolean; error?: string }>
}
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<Game[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set())
const [resyncingIds, setResyncingIds] = useState<Set<string>>(new Set())
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [syncing, setSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<SyncProgress>({
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<string, { success: boolean; error?: string }>()
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<string, { success: boolean; error?: string }>()
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 (
<div className="space-y-4">
{/* Search */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSyncAll}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50"
>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
Sync All
</button>
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 w-10">
<input
type="checkbox"
checked={selectedIds.size === games.length && games.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-border"
/>
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50 w-14">
Cover
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Title
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Developer
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Source
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Sync
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : games.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
No games found.
</td>
</tr>
) : (
games.map((game) => (
<tr
key={game.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedIds.has(game.id)}
onChange={(e) => handleSelectOne(game.id, e.target.checked)}
className="rounded border-border"
/>
</td>
<td className="px-4 py-3">
<div className="h-10 w-10 rounded overflow-hidden bg-text/5 flex items-center justify-center">
{game.capsuleImage || game.headerImage ? (
<Image
src={game.capsuleImage || game.headerImage || ""}
alt={game.title}
width={40}
height={40}
className="h-10 w-10 object-cover"
unoptimized
/>
) : (
<Gamepad2Icon className="h-4 w-4 text-text/40" />
)}
</div>
</td>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[200px]">
{game.title}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[150px]">
{game.developer || "—"}
</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
game.source === "steam"
? "bg-blue-500/10 text-blue-400"
: game.source === "manual"
? "bg-text/5 text-text/50"
: "bg-text/5 text-text/50"
}`}
>
{game.source}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
game.syncStatus === "synced"
? "bg-green-500/10 text-green-400"
: game.syncStatus === "failed"
? "bg-red-500/10 text-red-400"
: "bg-yellow-500/10 text-yellow-400"
}`}
title={game.syncStatus === "failed" ? game.syncError || undefined : undefined}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
game.syncStatus === "synced"
? "bg-green-400"
: game.syncStatus === "failed"
? "bg-red-400"
: "bg-yellow-400"
}`}
/>
{game.syncStatus === "synced" ? "Synced" : game.syncStatus === "failed" ? "Failed" : "Stale"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
href={`/game/${game.id}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
<button
onClick={() => handleResync(game)}
disabled={resyncingIds.has(game.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{resyncingIds.has(game.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCwIcon className="h-3.5 w-3.5" />
)}
Resync
</button>
<button
onClick={() => handleDelete(game)}
disabled={deletingIds.has(game.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{deletingIds.has(game.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Sync Progress Overlay — portaled to body for guaranteed viewport coverage */}
{(syncProgress.isRunning || syncCompleted) &&
createPortal(
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center">
<div className="w-full max-w-md mx-4 p-6 bg-background border border-border rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
{syncProgress.isRunning ? (
<>
<div className="flex items-center gap-3 mb-4">
<div className="relative">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCwIcon className="h-4 w-4 text-primary" />
</div>
</div>
<div>
<h3 className="font-semibold text-text">Syncing Games</h3>
<p className="text-sm text-text/50">
{syncProgress.current} of {syncProgress.total} games
</p>
</div>
</div>
{/* Progress bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-text/50 mb-1">
<span>Progress</span>
<span>{Math.round((syncProgress.current / syncProgress.total) * 100)}%</span>
</div>
<div className="h-2 bg-text/10 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${(syncProgress.current / syncProgress.total) * 100}%` }}
/>
</div>
</div>
{/* Current game */}
{syncProgress.currentGame && (
<div className="mb-4 p-3 bg-text/5 rounded-lg">
<p className="text-xs text-text/50 mb-1">Currently syncing:</p>
<p className="text-sm font-medium text-text truncate">{syncProgress.currentGame}</p>
</div>
)}
</>
) : (
<>
{/* Completed state header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CheckCircle2Icon className="h-8 w-8 text-green-400" />
<div>
<h3 className="font-semibold text-text">Sync Complete</h3>
<p className="text-sm text-text/50">
{syncProgress.synced} synced, {syncProgress.failed} failed
</p>
</div>
</div>
<button
onClick={closeSyncOverlay}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
Close
</button>
</div>
</>
)}
{/* Stats — shown in both running and completed states */}
<div className="flex gap-4 mb-4">
<div className="flex-1 p-3 bg-green-500/10 rounded-lg">
<div className="flex items-center gap-2">
<CheckCircle2Icon className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">{syncProgress.synced}</span>
</div>
<p className="text-xs text-text/50 mt-1">Synced</p>
</div>
<div className="flex-1 p-3 bg-red-500/10 rounded-lg">
<div className="flex items-center gap-2">
<XCircleIcon className="h-4 w-4 text-red-400" />
<span className="text-sm font-medium text-red-400">{syncProgress.failed}</span>
</div>
<p className="text-xs text-text/50 mt-1">Failed</p>
</div>
</div>
{/* Results list — scrollable */}
{syncProgress.results.size > 0 && (
<div className="max-h-48 overflow-y-auto space-y-1">
<p className="text-xs text-text/50 mb-2">
{syncCompleted ? "All results:" : "Recent results:"}
</p>
{(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 (
<div key={gameId} className="flex items-center gap-2 py-1">
{result.success ? (
<CheckCircle2Icon className="h-3 w-3 text-green-400 shrink-0" />
) : (
<XCircleIcon className="h-3 w-3 text-red-400 shrink-0" />
)}
<span className="text-xs text-text/70 truncate">
{game?.title || gameId}
</span>
{!result.success && result.error && (
<span className="text-xs text-red-400/70 ml-auto shrink-0">
{result.error}
</span>
)}
</div>
)
})}
</div>
)}
</div>
</div>,
document.body
)}
{/* Floating action bar */}
{selectedIds.size > 0 && !syncProgress.isRunning && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-3 bg-background border border-border rounded-xl shadow-lg">
<span className="text-sm text-text/70">
{selectedIds.size} game{selectedIds.size !== 1 ? "s" : ""} selected
</span>
<button
onClick={handleSyncSelected}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors text-sm font-medium cursor-pointer disabled:opacity-50"
>
<RefreshCwIcon className="h-4 w-4" />
Resync Selected
</button>
<button
onClick={handleSyncAll}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-text/5 text-text hover:bg-text/10 transition-colors text-sm font-medium cursor-pointer disabled:opacity-50"
>
<RefreshCwIcon className="h-4 w-4" />
Resync All
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-2 rounded-lg text-sm text-text/50 hover:text-text/70 transition-colors cursor-pointer"
>
Clear
</button>
</div>
)}
{/* Pagination */}
{games.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + games.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)
}