From cfb5940f3c391970a75deb47af9a556a2b0f072e Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 27 Apr 2026 21:31:48 +0800 Subject: [PATCH] feat: add admin games management page - Game list with search and pagination - Source and sync status badges - View on site link and delete action - Uses existing games CRUD API --- app/(admin)/admin/games/games-client.tsx | 288 +++++++++++++++++++++++ app/(admin)/admin/games/page.tsx | 10 + 2 files changed, 298 insertions(+) create mode 100644 app/(admin)/admin/games/games-client.tsx create mode 100644 app/(admin)/admin/games/page.tsx diff --git a/app/(admin)/admin/games/games-client.tsx b/app/(admin)/admin/games/games-client.tsx new file mode 100644 index 0000000..4d1afcf --- /dev/null +++ b/app/(admin)/admin/games/games-client.tsx @@ -0,0 +1,288 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import Image from "next/image" +import Link from "next/link" +import { + Loader2, + SearchIcon, + ExternalLinkIcon, + TrashIcon, + Gamepad2Icon, +} 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 + 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 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 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) => ( + + + + + + + + + )) + )} + +
+ Cover + + Title + + Developer + + Source + + Sync + + Actions +
+ +
+ No games found. +
+
+ {game.capsuleImage || game.headerImage ? ( + {game.title} + ) : ( + + )} +
+
+

+ {game.title} +

+
+

+ {game.developer || "—"} +

+
+ + {game.source} + + + + + {game.syncStatus === "synced" ? "Synced" : "Stale"} + + +
+ + + View + + +
+
+
+ + {/* Pagination */} + {games.length > 0 && ( +
+

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

+
+ + +
+
+ )} +
+ ) +} diff --git a/app/(admin)/admin/games/page.tsx b/app/(admin)/admin/games/page.tsx new file mode 100644 index 0000000..e782749 --- /dev/null +++ b/app/(admin)/admin/games/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { GamesClient } from "./games-client" + +export const metadata: Metadata = { + title: "Games", +} + +export default function GamesPage() { + return +}