From 0a6f5ac8d2cb657f60b4fee3d06de202aba28d27 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 29 Apr 2026 00:32:44 +0800 Subject: [PATCH] feat: non-Steam game edit page and edit button on game details --- app/game/[id]/edit/page.tsx | 90 +++++++ app/game/[id]/game-page-client.tsx | 10 + components/wizard/non-steam-edit-form.tsx | 278 ++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 app/game/[id]/edit/page.tsx create mode 100644 components/wizard/non-steam-edit-form.tsx diff --git a/app/game/[id]/edit/page.tsx b/app/game/[id]/edit/page.tsx new file mode 100644 index 0000000..30772c4 --- /dev/null +++ b/app/game/[id]/edit/page.tsx @@ -0,0 +1,90 @@ +import { notFound, redirect } from "next/navigation" +import { headers } from "next/headers" +import { db } from "@/lib/db/index" +import { games, gamePlatformSupport, hardware } from "@/lib/db/schema" +import { eq } from "drizzle-orm" +import { auth } from "@/lib/auth" +import { NonSteamEditForm } from "@/components/wizard/non-steam-edit-form" + +export const dynamic = "force-dynamic" + +export const metadata = { + title: "Edit Game", +} + +async function resolveGame(id: string) { + const isNumeric = /^\d+$/.test(id) + if (isNumeric) { + const rows = await db.select().from(games).where(eq(games.steamAppId, Number(id))).limit(1) + return rows[0] + } + const rows = await db.select().from(games).where(eq(games.id, id)).limit(1) + return rows[0] +} + +export default async function EditGamePage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const h = await headers() + const session = await auth.api.getSession({ headers: h }) + if (!session?.user) redirect("/login") + + const game = await resolveGame(id) + if (!game) notFound() + + if (game.source === "steam") { + notFound() + } + + const platformSupport = await db + .select() + .from(gamePlatformSupport) + .where(eq(gamePlatformSupport.gameId, game.id)) + + const hardwareList = await db + .select({ slug: hardware.slug, name: hardware.name, deviceType: hardware.deviceType }) + .from(hardware) + .orderBy(hardware.sortOrder) + + const serializedGame = { + id: game.id, + title: game.title, + developer: game.developer, + publisher: game.publisher, + description: game.description, + source: game.source, + storeUrl: game.storeUrl, + headerImage: game.headerImage, + capsuleImage: game.capsuleImage, + genres: game.genres, + releaseDate: game.releaseDate, + createdBy: game.createdBy, + } + + const serializedPlatformSupport = platformSupport.map(ps => ({ + hardwareSlug: ps.hardwareSlug, + isSupported: ps.isSupported, + protonStatus: ps.protonStatus, + })) + + return ( +
+
+

Edit Game

+

+ Update details for {game.title} +

+
+ +
+ ) +} diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 8637656..68334b4 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -17,6 +17,7 @@ import { ThumbsDownIcon, GaugeIcon, ChevronDownIcon, + PencilIcon, } from "lucide-react" import Link from "next/link" import { useSession } from "@/lib/auth-client" @@ -589,6 +590,15 @@ export function GamePageClient({ {stats && (
+ {session?.user && game.source !== "steam" && ( + + + Edit Game + + )} {session && ( (null) + + const [title, setTitle] = useState(game.title ?? "") + const [developer, setDeveloper] = useState(game.developer ?? "") + const [publisher, setPublisher] = useState(game.publisher ?? "") + const [description, setDescription] = useState(game.description ?? "") + const [storeUrl, setStoreUrl] = useState(game.storeUrl ?? "") + const [headerImage, setHeaderImage] = useState(game.headerImage ?? "") + const [capsuleImage, setCapsuleImage] = useState(game.capsuleImage ?? "") + const [genresStr, setGenresStr] = useState(game.genres?.join(", ") ?? "") + const [releaseDate, setReleaseDate] = useState(game.releaseDate ?? "") + const [platforms, setPlatforms] = useState(platformSupport) + + const canEdit = isOwner || isAdmin + + const handleTogglePlatform = (slug: string) => { + setPlatforms(prev => { + const existing = prev.find(p => p.hardwareSlug === slug) + if (existing) { + return prev.filter(p => p.hardwareSlug !== slug) + } + return [...prev, { hardwareSlug: slug, isSupported: true, protonStatus: "unknown" }] + }) + } + + const handleProtonChange = (slug: string, protonStatus: string) => { + setPlatforms(prev => + prev.map(p => p.hardwareSlug === slug ? { ...p, protonStatus } : p) + ) + } + + const handleSubmit = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/games/${game.id}/manual`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + developer: developer.trim() || null, + publisher: publisher.trim() || null, + description: description.trim() || null, + storeUrl: storeUrl.trim() || null, + headerImage: headerImage.trim() || null, + capsuleImage: capsuleImage.trim() || null, + genres: genresStr.split(",").map(g => g.trim()).filter(Boolean), + releaseDate: releaseDate.trim() || null, + }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || "Failed to update game") + } + router.push(`/game/${game.id}`) + router.refresh() + } catch (err: any) { + setError(err.message || "Failed to update game") + } finally { + setLoading(false) + } + } + + if (!canEdit) { + return ( +
+ +

Only the creator or an admin can edit this game.

+
+ ) + } + + return ( +
+ {/* Basic Info */} +
+

Basic Info

+
+
+ + setTitle(e.target.value)} + className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary" + /> +
+
+ + setDeveloper(e.target.value)} + className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary" + /> +
+
+ + setPublisher(e.target.value)} + className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary" + /> +
+
+ + setStoreUrl(e.target.value)} + className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary" + /> +
+
+
+ +