feat: non-Steam game edit page and edit button on game details
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8 w-full">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Edit Game</h1>
|
||||||
|
<p className="text-sm text-text/60">
|
||||||
|
Update details for <span className="text-text font-medium">{game.title}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NonSteamEditForm
|
||||||
|
game={serializedGame}
|
||||||
|
platformSupport={serializedPlatformSupport}
|
||||||
|
hardwareList={hardwareList}
|
||||||
|
isOwner={session.user.id === game.createdBy}
|
||||||
|
isAdmin={session.user.role === "admin"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ThumbsDownIcon,
|
ThumbsDownIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
PencilIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useSession } from "@/lib/auth-client"
|
import { useSession } from "@/lib/auth-client"
|
||||||
@@ -589,6 +590,15 @@ export function GamePageClient({
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className='flex flex-wrap items-center gap-2 mt-2'>
|
<div className='flex flex-wrap items-center gap-2 mt-2'>
|
||||||
<BookmarkButton gameId={game.id} />
|
<BookmarkButton gameId={game.id} />
|
||||||
|
{session?.user && game.source !== "steam" && (
|
||||||
|
<Link
|
||||||
|
href={`/game/${gameId}/edit`}
|
||||||
|
className='inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm text-text/70 hover:bg-text/5 transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<PencilIcon className='h-4 w-4' />
|
||||||
|
Edit Game
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{session && (
|
{session && (
|
||||||
<Link
|
<Link
|
||||||
href={`/game/${game.id}/submit`}
|
href={`/game/${game.id}/submit`}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { SettingsIcon, Gamepad2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
interface HardwareDevice {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
deviceType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformSupportEntry {
|
||||||
|
hardwareSlug: string
|
||||||
|
isSupported: boolean
|
||||||
|
protonStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameData {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
developer: string | null
|
||||||
|
publisher: string | null
|
||||||
|
description: string | null
|
||||||
|
source: string
|
||||||
|
storeUrl: string | null
|
||||||
|
headerImage: string | null
|
||||||
|
capsuleImage: string | null
|
||||||
|
genres: string[] | null
|
||||||
|
releaseDate: string | null
|
||||||
|
createdBy: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameData
|
||||||
|
platformSupport: PlatformSupportEntry[]
|
||||||
|
hardwareList: HardwareDevice[]
|
||||||
|
isOwner: boolean
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NonSteamEditForm({ game, platformSupport, hardwareList, isOwner, isAdmin }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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<PlatformSupportEntry[]>(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 (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||||
|
<SettingsIcon className="h-10 w-10 text-text/20" />
|
||||||
|
<p className="text-sm text-text/40">Only the creator or an admin can edit this game.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Basic Info</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Developer</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={developer}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Publisher</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={publisher}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Store URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={storeUrl}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Genres (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={genresStr}
|
||||||
|
onChange={e => setGenresStr(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Release Date</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={releaseDate}
|
||||||
|
onChange={e => setReleaseDate(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Images</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Header Image URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={headerImage}
|
||||||
|
onChange={e => setHeaderImage(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Capsule Image URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={capsuleImage}
|
||||||
|
onChange={e => setCapsuleImage(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform Support */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Platform Support</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{hardwareList.map(device => {
|
||||||
|
const active = platforms.find(p => p.hardwareSlug === device.slug)
|
||||||
|
return (
|
||||||
|
<div key={device.slug} className="flex items-center justify-between p-3 rounded-lg border border-border bg-text/3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Gamepad2Icon className="h-4 w-4 text-text/50" />
|
||||||
|
<span className="text-sm font-medium">{device.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePlatform(device.slug)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? "bg-green-500/20 border-green-500/30 text-green-400"
|
||||||
|
: "border-border text-text/40 hover:text-text/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active ? "Supported" : "Unsupported"}
|
||||||
|
</button>
|
||||||
|
{active && (
|
||||||
|
<select
|
||||||
|
value={active.protonStatus}
|
||||||
|
onChange={e => handleProtonChange(device.slug, e.target.value)}
|
||||||
|
className="text-xs bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
<option value="native">Native</option>
|
||||||
|
<option value="proton">Proton</option>
|
||||||
|
<option value="unsupported">Unsupported</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-4 py-2 rounded-lg border border-border text-sm text-text/70 hover:bg-text/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !title.trim()}
|
||||||
|
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user