feat: non-Steam game submission wizard with SteamGridDB integration

This commit is contained in:
2026-04-29 00:00:10 +08:00
parent 74fe0447ee
commit c6d4e7b3d1
10 changed files with 1303 additions and 0 deletions
+3
View File
@@ -23,6 +23,7 @@ import { steamSearchRoutes } from "@/lib/api/steam-search"
import { searchUnifiedRoutes } from "@/lib/api/search-unified" import { searchUnifiedRoutes } from "@/lib/api/search-unified"
import { gameStubRoutes } from "@/lib/api/game-stub" import { gameStubRoutes } from "@/lib/api/game-stub"
import { gameStatsRoutes } from "@/lib/api/game-stats" import { gameStatsRoutes } from "@/lib/api/game-stats"
import { gamesManualRoutes } from "@/lib/api/games-manual"
import { gamesListingRoutes } from "@/lib/api/games-listing" import { gamesListingRoutes } from "@/lib/api/games-listing"
import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy" import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
@@ -89,6 +90,8 @@ export const app = new Elysia({ prefix: "/api" })
.use(steamgridProxyRoutes) .use(steamgridProxyRoutes)
// Game stats aggregation // Game stats aggregation
.use(gameStatsRoutes) .use(gameStatsRoutes)
// Manual game creation
.use(gamesManualRoutes)
// Saved games // Saved games
.use(savedGamesRoutes) .use(savedGamesRoutes)
// Contact form // Contact form
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation"
import { headers } from "next/headers"
import { auth } from "@/lib/auth"
import { NonSteamWizard } from "@/components/wizard/non-steam-wizard"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Add Non-Steam Game",
}
export default async function AddGamePage() {
const h = await headers()
const session = await auth.api.getSession({ headers: h })
if (!session?.user) {
redirect("/login")
}
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">Add a Game</h1>
<p className="text-sm text-text/60">
Add a non-Steam game to DeckyVault. Search for cover art, set platform support, and submit.
</p>
</div>
<NonSteamWizard />
</div>
)
}
+14
View File
@@ -14,6 +14,8 @@ import {
} from "lucide-react" } from "lucide-react"
import { FaSteam } from "react-icons/fa" import { FaSteam } from "react-icons/fa"
import Image from "next/image" import Image from "next/image"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons" import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons"
interface UnifiedResult { interface UnifiedResult {
@@ -49,6 +51,7 @@ interface UnifiedResult {
function SearchContent() { function SearchContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const { data: session } = useSession()
const query = searchParams.get("q") || "" const query = searchParams.get("q") || ""
const [results, setResults] = useState<UnifiedResult[]>([]) const [results, setResults] = useState<UnifiedResult[]>([])
@@ -191,6 +194,17 @@ function SearchContent() {
</AnimatePresence> </AnimatePresence>
</motion.div> </motion.div>
)} )}
{session?.user && (
<div className="mt-6 text-center">
<p className="text-sm text-text/50">
Can&apos;t find your game?{" "}
<Link href="/game/add" className="text-primary hover:underline cursor-pointer">
Add it manually
</Link>
</p>
</div>
)}
</div> </div>
</section> </section>
) )
+234
View File
@@ -0,0 +1,234 @@
"use client"
import { useState, useCallback } from "react"
import { useRouter } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { StepIndicator } from "./step-indicator"
import { NonSteamBasicInfoStep, BasicInfoData } from "./steps/non-steam-basic-info-step"
import { NonSteamImageStep } from "./steps/non-steam-image-step"
import { NonSteamPlatformStep, PlatformSupportItem } from "./steps/non-steam-platform-step"
import { NonSteamReviewStep } from "./steps/non-steam-review-step"
const STEPS = [
{ label: "Basic Info", tooltip: "Enter the game title, developer, publisher, and other details." },
{ label: "Cover Art", tooltip: "Search SteamGridDB for cover art or enter an image URL." },
{ label: "Platform Support", tooltip: "Select supported devices and Proton compatibility." },
{ label: "Review", tooltip: "Review all details before submitting the game." },
]
interface FormData {
basicInfo: BasicInfoData
headerImage: string
capsuleImage: string
platformSupport: PlatformSupportItem[]
}
export function NonSteamWizard() {
const router = useRouter()
const [step, setStep] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [formData, setFormData] = useState<FormData>({
basicInfo: {
title: "",
developer: "",
publisher: "",
description: "",
source: "manual",
storeUrl: "",
genres: [],
releaseDate: "",
},
headerImage: "",
capsuleImage: "",
platformSupport: [],
})
const updateBasicInfo = useCallback(
(value: BasicInfoData) => {
setFormData((prev) => ({ ...prev, basicInfo: value }))
},
[]
)
const updateImages = useCallback(
(headerImage: string, capsuleImage: string) => {
setFormData((prev) => ({ ...prev, headerImage, capsuleImage }))
},
[]
)
const updatePlatformSupport = useCallback(
(value: PlatformSupportItem[]) => {
setFormData((prev) => ({ ...prev, platformSupport: value }))
},
[]
)
const handleSubmit = async () => {
setLoading(true)
setError(null)
try {
const payload = {
title: formData.basicInfo.title,
developer: formData.basicInfo.developer || undefined,
publisher: formData.basicInfo.publisher || undefined,
description: formData.basicInfo.description || undefined,
source: formData.basicInfo.source,
storeUrl: formData.basicInfo.storeUrl || undefined,
genres: formData.basicInfo.genres.length > 0 ? formData.basicInfo.genres : undefined,
releaseDate: formData.basicInfo.releaseDate || undefined,
headerImage: formData.headerImage || undefined,
capsuleImage: formData.capsuleImage || undefined,
platformSupport: formData.platformSupport.map((ps) => ({
hardwareSlug: ps.hardwareSlug,
isSupported: ps.isSupported,
protonStatus: ps.protonStatus,
})),
}
const res = await fetch("/api/games/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) {
if (res.status === 409 && data.existingGame) {
setError(`Game already exists: "${data.existingGame.title}". Redirecting...`)
setTimeout(() => router.push(`/game/${data.existingGame.id}`), 2000)
return
}
throw new Error(data.error || "Failed to create game")
}
setSuccess(true)
setTimeout(() => router.push(`/game/${data.game.id}`), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create game")
} finally {
setLoading(false)
}
}
const canProceed = () => {
switch (step) {
case 0:
return formData.basicInfo.title.trim().length > 0
default:
return true
}
}
const handleNext = () => {
if (step < STEPS.length - 1 && canProceed()) {
setStep(step + 1)
}
}
const handleBack = () => {
if (step > 0) {
setStep(step - 1)
}
}
const handleStepClick = (clickedStep: number) => {
if (clickedStep <= step) {
setStep(clickedStep)
}
}
if (success) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center justify-center py-16 text-center"
>
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold mb-2">Game Created!</h2>
<p className="text-sm text-text/60">Redirecting to game page...</p>
</motion.div>
)
}
return (
<div className="space-y-8">
<StepIndicator steps={STEPS} currentStep={step} onStepClick={handleStepClick} />
<div className="flex items-center gap-1 text-xs text-text/40">
<span className="text-red-400">*</span> Required fields
</div>
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="min-h-[300px]"
>
{step === 0 && (
<NonSteamBasicInfoStep
value={formData.basicInfo}
onChange={updateBasicInfo}
/>
)}
{step === 1 && (
<NonSteamImageStep
headerImage={formData.headerImage}
capsuleImage={formData.capsuleImage}
onChange={updateImages}
/>
)}
{step === 2 && (
<NonSteamPlatformStep
value={formData.platformSupport}
onChange={updatePlatformSupport}
/>
)}
{step === 3 && (
<NonSteamReviewStep
basicInfo={formData.basicInfo}
headerImage={formData.headerImage}
capsuleImage={formData.capsuleImage}
platformSupport={formData.platformSupport}
onSubmit={handleSubmit}
isSubmitting={loading}
error={error}
/>
)}
</motion.div>
</AnimatePresence>
{/* Navigation Buttons */}
{step < STEPS.length - 1 && (
<div className="flex justify-between">
<button
type="button"
onClick={handleBack}
disabled={step === 0}
className="px-6 py-2 rounded-lg border border-border text-sm font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
>
Back
</button>
<button
type="button"
onClick={handleNext}
disabled={!canProceed()}
className="px-6 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Next
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,242 @@
"use client"
import { useState, useCallback, useRef } from "react"
import { Info, AlertTriangle, Loader2 } from "lucide-react"
export interface BasicInfoData {
title: string
developer: string
publisher: string
description: string
source: "manual" | "gog" | "epic"
storeUrl: string
genres: string[]
releaseDate: string
}
interface NonSteamBasicInfoStepProps {
value: BasicInfoData
onChange: (value: BasicInfoData) => void
}
interface DuplicateHint {
id: string
title: string
source: string
}
export function NonSteamBasicInfoStep({ value, onChange }: NonSteamBasicInfoStepProps) {
const [checking, setChecking] = useState(false)
const [duplicates, setDuplicates] = useState<DuplicateHint[]>([])
const [genreInput, setGenreInput] = useState(value.genres.join(", "))
const lastCheckedTitle = useRef("")
const update = (field: keyof BasicInfoData, val: string | string[]) => {
onChange({ ...value, [field]: val })
}
const checkDuplicates = useCallback(async (title: string) => {
if (!title.trim() || title.trim().length < 2) {
setDuplicates([])
return
}
if (lastCheckedTitle.current === title.trim()) return
lastCheckedTitle.current = title.trim()
setChecking(true)
try {
const res = await fetch(`/api/search/unified?q=${encodeURIComponent(title)}`)
if (!res.ok) {
setDuplicates([])
return
}
const data = await res.json()
const results = (data.results || []) as Array<{
id?: string
title: string
source: string
}>
const matches = results
.filter(
(r) =>
r.title.toLowerCase().includes(title.toLowerCase()) ||
title.toLowerCase().includes(r.title.toLowerCase())
)
.slice(0, 3)
.map((r) => ({
id: r.id || String(r.title),
title: r.title,
source: r.source,
}))
setDuplicates(matches)
} catch {
setDuplicates([])
} finally {
setChecking(false)
}
}, [])
const handleGenreBlur = () => {
const parsed = genreInput
.split(",")
.map((g) => g.trim())
.filter((g) => g.length > 0)
onChange({ ...value, genres: parsed })
}
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Basic Info</h3>
<p className="text-xs text-text/60 mt-1">
Enter the game details. Title is required.
</p>
</div>
</div>
{/* Title */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">
Title <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type="text"
value={value.title}
onChange={(e) => update("title", e.target.value)}
onBlur={(e) => checkDuplicates(e.target.value)}
placeholder="e.g. Hollow Knight"
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{checking && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-text/30" />
)}
</div>
{duplicates.length > 0 && (
<div className="flex items-start gap-2 mt-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-400">
<span className="font-medium">Did you mean:</span>{" "}
{duplicates.map((d, i) => (
<span key={d.id}>
<a
href={`/game/${d.id}`}
className="underline hover:text-amber-300"
target="_blank"
rel="noopener noreferrer"
>
{d.title}
</a>
{i < duplicates.length - 1 ? ", " : ""}
</span>
))}
</div>
</div>
)}
</div>
{/* Source */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Source</label>
<div className="relative">
<select
value={value.source}
onChange={(e) =>
update("source", e.target.value as "manual" | "gog" | "epic")
}
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
>
<option value="manual">Manual Entry</option>
<option value="gog">GOG</option>
<option value="epic">Epic Games Store</option>
</select>
<svg
className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Developer / Publisher */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Developer</label>
<input
type="text"
value={value.developer}
onChange={(e) => update("developer", e.target.value)}
placeholder="e.g. Team Cherry"
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Publisher</label>
<input
type="text"
value={value.publisher}
onChange={(e) => update("publisher", e.target.value)}
placeholder="e.g. Team Cherry"
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Description</label>
<textarea
value={value.description}
onChange={(e) => update("description", e.target.value)}
placeholder="Short game description..."
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 focus:ring-2 focus:ring-primary/50 transition-colors resize-none"
/>
</div>
{/* Store URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Store URL</label>
<input
type="url"
value={value.storeUrl}
onChange={(e) => update("storeUrl", e.target.value)}
placeholder="https://..."
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
{/* Genres */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Genres</label>
<input
type="text"
value={genreInput}
onChange={(e) => setGenreInput(e.target.value)}
onBlur={handleGenreBlur}
placeholder="Action, Adventure, Platformer"
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
<p className="text-[10px] text-text/30">Comma-separated list</p>
</div>
{/* Release Date */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Release Date</label>
<input
type="text"
value={value.releaseDate}
onChange={(e) => update("releaseDate", e.target.value)}
placeholder="e.g. 2017-02-24"
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
</div>
)
}
@@ -0,0 +1,263 @@
"use client"
import { useState, useCallback } from "react"
import { ImageIcon, Loader2, Search, ExternalLink } from "lucide-react"
import { motion, AnimatePresence } from "motion/react"
interface SteamGridResult {
id: number
name: string
}
interface SteamGridImage {
id: number
url: string
thumb: string
width: number
height: number
}
interface NonSteamImageStepProps {
headerImage: string
capsuleImage: string
onChange: (headerImage: string, capsuleImage: string) => void
}
export function NonSteamImageStep({
headerImage,
capsuleImage,
onChange,
}: NonSteamImageStepProps) {
const [query, setQuery] = useState("")
const [searching, setSearching] = useState(false)
const [loadingGrids, setLoadingGrids] = useState(false)
const [results, setResults] = useState<SteamGridResult[]>([])
const [grids, setGrids] = useState<SteamGridImage[]>([])
const [selectedGameId, setSelectedGameId] = useState<number | null>(null)
const [manualUrl, setManualUrl] = useState("")
const [showManual, setShowManual] = useState(false)
const search = useCallback(async () => {
if (!query.trim() || query.trim().length < 2) return
setSearching(true)
setResults([])
setGrids([])
setSelectedGameId(null)
try {
const res = await fetch(`/api/steamgrid/search?q=${encodeURIComponent(query)}`)
if (!res.ok) throw new Error("Search failed")
const data = await res.json()
setResults((data.data || []).slice(0, 8))
} catch {
setResults([])
} finally {
setSearching(false)
}
}, [query])
const fetchGrids = useCallback(async (gameId: number) => {
setLoadingGrids(true)
setGrids([])
setSelectedGameId(gameId)
try {
const res = await fetch(`/api/steamgrid/grids/${gameId}?styles=alternate`)
if (!res.ok) throw new Error("Failed to fetch grids")
const data = await res.json()
const images: SteamGridImage[] = (data.data || [])
.filter((g: SteamGridImage) => g.url)
.sort((a: SteamGridImage, b: SteamGridImage) => {
// Prefer 600x900
const aScore = a.width === 600 && a.height === 900 ? 2 : a.width === 342 ? 1 : 0
const bScore = b.width === 600 && b.height === 900 ? 2 : b.width === 342 ? 1 : 0
return bScore - aScore
})
setGrids(images.slice(0, 12))
} catch {
setGrids([])
} finally {
setLoadingGrids(false)
}
}, [])
const handleSelectImage = (url: string) => {
// Use the same image for both header and capsule
onChange(url, url)
}
const handleManualSubmit = () => {
if (!manualUrl.trim()) return
onChange(manualUrl.trim(), manualUrl.trim())
}
const currentImage = capsuleImage || headerImage
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<ImageIcon className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Cover Art</h3>
<p className="text-xs text-text/60 mt-1">
Search SteamGridDB for cover art, or paste an image URL manually.
</p>
</div>
</div>
{/* Search */}
<div className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="Search SteamGridDB..."
className="w-full px-4 py-3 pr-10 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/30" />
</div>
<button
type="button"
onClick={search}
disabled={searching || query.trim().length < 2}
className="px-4 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : "Search"}
</button>
</div>
{/* Results */}
<AnimatePresence>
{results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
className="flex flex-wrap gap-2"
>
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => fetchGrids(r.id)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer ${
selectedGameId === r.id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-text/5 text-text/70 hover:bg-text/10 hover:border-text/30"
}`}
>
{r.name}
</button>
))}
</motion.div>
)}
</AnimatePresence>
{/* Grid images */}
{loadingGrids && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)}
<AnimatePresence>
{grids.length > 0 && !loadingGrids && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2"
>
{grids.map((g) => {
const isSelected = capsuleImage === g.url
return (
<button
key={g.id}
type="button"
onClick={() => handleSelectImage(g.url)}
className={`relative aspect-[2/3] rounded-lg overflow-hidden border transition-all cursor-pointer ${
isSelected
? "border-primary ring-2 ring-primary/50"
: "border-border hover:border-text/30"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={g.thumb || g.url}
alt="Cover"
className="w-full h-full object-cover"
loading="lazy"
/>
{isSelected && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/20">
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-white" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
)}
</button>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Manual URL */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowManual(!showManual)}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{showManual ? "Hide manual URL input" : "Enter image URL manually"}
</button>
<AnimatePresence>
{showManual && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-2 overflow-hidden"
>
<input
type="url"
value={manualUrl}
onChange={(e) => setManualUrl(e.target.value)}
placeholder="https://example.com/cover.jpg"
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 focus:ring-2 focus:ring-primary/50 transition-colors"
/>
<button
type="button"
onClick={handleManualSubmit}
disabled={!manualUrl.trim()}
className="px-4 py-2 rounded-lg border border-border text-xs font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-40 cursor-pointer"
>
Use this URL
</button>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Preview */}
{currentImage && (
<div className="space-y-2">
<p className="text-xs font-medium text-text/60">Selected Cover</p>
<div className="w-32 aspect-[2/3] rounded-lg overflow-hidden border border-border bg-text/5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={currentImage}
alt="Selected cover"
className="w-full h-full object-cover"
/>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,223 @@
"use client"
import { useState, useEffect } from "react"
import { motion, AnimatePresence } from "motion/react"
import { Monitor, Gamepad2, Loader2 } from "lucide-react"
interface HardwareDevice {
slug: string
name: string
deviceType: string
}
export interface PlatformSupportItem {
hardwareSlug: string
isSupported: boolean
protonStatus: "native" | "proton" | "unsupported" | "unknown"
}
interface NonSteamPlatformStepProps {
value: PlatformSupportItem[]
onChange: (value: PlatformSupportItem[]) => void
}
const PROTON_OPTIONS: { value: PlatformSupportItem["protonStatus"]; label: string }[] = [
{ value: "native", label: "Native" },
{ value: "proton", label: "Proton" },
{ value: "unsupported", label: "Unsupported" },
{ value: "unknown", label: "Unknown" },
]
export function NonSteamPlatformStep({ value, onChange }: NonSteamPlatformStepProps) {
const [devices, setDevices] = useState<HardwareDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function fetchHardware() {
try {
setLoading(true)
setError(null)
const res = await fetch("/api/performance/hardware")
if (!res.ok) throw new Error("Failed to load hardware")
const data = await res.json()
if (!cancelled) {
setDevices(data.data || [])
}
} catch {
if (!cancelled) {
setError("Failed to load hardware devices")
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchHardware()
return () => { cancelled = true }
}, [])
const getItem = (slug: string): PlatformSupportItem => {
return (
value.find((v) => v.hardwareSlug === slug) || {
hardwareSlug: slug,
isSupported: false,
protonStatus: "unknown",
}
)
}
const updateItem = (slug: string, patch: Partial<PlatformSupportItem>) => {
const existing = value.find((v) => v.hardwareSlug === slug)
let next: PlatformSupportItem[]
if (existing) {
next = value.map((v) =>
v.hardwareSlug === slug ? { ...v, ...patch } : v
)
} else {
next = [
...value,
{
hardwareSlug: slug,
isSupported: patch.isSupported ?? false,
protonStatus: patch.protonStatus ?? "unknown",
},
]
}
onChange(next)
}
const toggleSupported = (slug: string) => {
const item = getItem(slug)
updateItem(slug, { isSupported: !item.isSupported })
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-text/60">Loading hardware devices...</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16">
<p className="text-sm text-red-400">{error}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<Monitor className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Platform Support</h3>
<p className="text-xs text-text/60 mt-1">
Select devices this game supports and its Proton status.
</p>
</div>
</div>
<div className="flex flex-col gap-3">
{devices.map((device) => {
const item = getItem(device.slug)
const Icon = device.deviceType === "console" ? Gamepad2 : Monitor
return (
<motion.div
key={device.slug}
layout
className={`rounded-lg border p-4 transition-colors ${
item.isSupported
? "border-primary/30 bg-primary/5"
: "border-border bg-text/[0.02]"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
item.isSupported
? "bg-primary/15 text-primary"
: "bg-text/10 text-text/50"
}`}
>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-text">{device.name}</p>
<p className="text-[11px] text-text/40 capitalize">
{device.deviceType}
</p>
</div>
</div>
<button
type="button"
onClick={() => toggleSupported(device.slug)}
className={`relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${
item.isSupported ? "bg-primary" : "bg-text/20"
}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5 ${
item.isSupported ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
<AnimatePresence>
{item.isSupported && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 pt-3 border-t border-border/50 overflow-hidden"
>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">
Proton Status
</label>
<div className="flex flex-wrap gap-2">
{PROTON_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() =>
updateItem(device.slug, { protonStatus: opt.value })
}
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors cursor-pointer ${
item.protonStatus === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-text/5 text-text/60 hover:bg-text/10"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
})}
{devices.length === 0 && (
<div className="flex flex-col items-center justify-center py-10 border border-dashed border-border rounded-lg bg-text/5">
<p className="text-sm text-text/50">No hardware devices available</p>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,198 @@
"use client"
import { ImageIcon, Monitor, Info, FileText, Link, Calendar } from "lucide-react"
import { BasicInfoData } from "./non-steam-basic-info-step"
import { PlatformSupportItem } from "./non-steam-platform-step"
interface NonSteamReviewStepProps {
basicInfo: BasicInfoData
headerImage: string
capsuleImage: string
platformSupport: PlatformSupportItem[]
onSubmit: () => void
isSubmitting: boolean
error: string | null
}
function SectionHeader({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
return (
<div className="flex items-center gap-2 mb-2">
<Icon className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold text-text/80 uppercase tracking-wider">{label}</span>
</div>
)
}
function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-border/50 last:border-b-0">
<span className="text-xs text-text/50">{label}</span>
<span className="text-xs text-text font-medium">{value}</span>
</div>
)
}
const SOURCE_LABELS: Record<string, string> = {
manual: "Manual Entry",
gog: "GOG",
epic: "Epic Games Store",
}
const PROTON_LABELS: Record<string, string> = {
native: "Native",
proton: "Proton",
unsupported: "Unsupported",
unknown: "Unknown",
}
export function NonSteamReviewStep({
basicInfo,
headerImage,
capsuleImage,
platformSupport,
onSubmit,
isSubmitting,
error,
}: NonSteamReviewStepProps) {
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Review & Submit</h3>
<p className="text-xs text-text/60 mt-1">
Review all details before submitting the game to DeckyVault.
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Basic Info */}
<div className="rounded-lg border border-border bg-text/5 p-4">
<SectionHeader icon={FileText} label="Basic Info" />
<SummaryRow label="Title" value={basicInfo.title || "—"} />
<SummaryRow label="Source" value={SOURCE_LABELS[basicInfo.source] || basicInfo.source} />
<SummaryRow label="Developer" value={basicInfo.developer || "—"} />
<SummaryRow label="Publisher" value={basicInfo.publisher || "—"} />
<SummaryRow
label="Genres"
value={
basicInfo.genres.length > 0 ? basicInfo.genres.join(", ") : "—"
}
/>
<SummaryRow label="Release Date" value={basicInfo.releaseDate || "—"} />
{basicInfo.storeUrl && (
<SummaryRow
label="Store URL"
value={
<a
href={basicInfo.storeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline truncate max-w-[200px] block"
>
<span className="inline-flex items-center gap-1">
<Link className="h-3 w-3" />
{basicInfo.storeUrl}
</span>
</a>
}
/>
)}
{basicInfo.description && (
<div className="mt-2">
<p className="text-[10px] text-text/40 mb-1">Description</p>
<p className="text-[11px] text-text/70 leading-relaxed">{basicInfo.description}</p>
</div>
)}
</div>
{/* Cover Art */}
<div className="rounded-lg border border-border bg-text/5 p-4">
<SectionHeader icon={ImageIcon} label="Cover Art" />
{(headerImage || capsuleImage) ? (
<div className="w-28 aspect-[2/3] rounded-lg overflow-hidden border border-border bg-text/5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={capsuleImage || headerImage}
alt="Cover preview"
className="w-full h-full object-cover"
/>
</div>
) : (
<p className="text-xs text-text/40">No cover art selected</p>
)}
</div>
{/* Platform Support */}
<div className="rounded-lg border border-border bg-text/5 p-4 lg:col-span-2">
<SectionHeader icon={Monitor} label="Platform Support" />
{platformSupport.length > 0 ? (
<div className="flex flex-wrap gap-2">
{platformSupport.map((ps) => (
<span
key={ps.hardwareSlug}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs border ${
ps.isSupported
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-text/5 text-text/40"
}`}
>
{ps.isSupported ? (
<>
{ps.hardwareSlug}
<span className="text-text/40">·</span>
{PROTON_LABELS[ps.protonStatus] || ps.protonStatus}
</>
) : (
<>{ps.hardwareSlug} Unsupported</>
)}
</span>
))}
</div>
) : (
<p className="text-xs text-text/40">No platform support configured</p>
)}
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3">
<span className="text-xs text-red-400">{error}</span>
</div>
)}
{/* Submit */}
<button
type="button"
onClick={onSubmit}
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isSubmitting ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Submitting...
</>
) : (
"Submit Game"
)}
</button>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import { games, gameVersions, gamePlatformSupport } from "@/lib/db/schema"
import { ilike } from "drizzle-orm"
import { requireRole } from "@/lib/auth/guard"
export const gamesManualRoutes = new Elysia({ prefix: "/games" })
.post(
"/manual",
async ({ request, body, set }) => {
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
if (!guard.ok) {
set.status = guard.status
return { error: guard.error }
}
// Duplicate check: exact title match (case-insensitive)
const existing = await db
.select({ id: games.id, title: games.title, source: games.source })
.from(games)
.where(ilike(games.title, body.title))
.limit(5)
if (existing.length > 0) {
const exactMatch = existing.find(
(g) => g.title.toLowerCase() === body.title.toLowerCase()
)
if (exactMatch) {
set.status = 409
return { error: "Game already exists", existingGame: exactMatch }
}
}
// Create the game
const [game] = await db
.insert(games)
.values({
title: body.title,
developer: body.developer || null,
publisher: body.publisher || null,
description: body.description || null,
source: body.source || "manual",
headerImage: body.headerImage || null,
capsuleImage: body.capsuleImage || null,
storeUrl: body.storeUrl || null,
genres: body.genres || null,
releaseDate: body.releaseDate || null,
})
.returning()
// Create platform support entries
if (body.platformSupport && body.platformSupport.length > 0) {
await db.insert(gamePlatformSupport).values(
body.platformSupport.map((ps) => ({
gameId: game.id,
hardwareSlug: ps.hardwareSlug,
isSupported: ps.isSupported,
protonStatus: ps.protonStatus as "native" | "proton" | "unsupported" | "unknown",
}))
)
}
// Create initial game version
await db.insert(gameVersions).values({
gameId: game.id,
isLatest: true,
})
set.status = 201
return { game }
},
{
body: t.Object({
title: t.String({ minLength: 1, maxLength: 200 }),
developer: t.Optional(t.String()),
publisher: t.Optional(t.String()),
description: t.Optional(t.String()),
source: t.Optional(t.Union([t.Literal("manual"), t.Literal("gog"), t.Literal("epic")])),
headerImage: t.Optional(t.String()),
capsuleImage: t.Optional(t.String()),
storeUrl: t.Optional(t.String()),
genres: t.Optional(t.Array(t.String())),
releaseDate: t.Optional(t.String()),
platformSupport: t.Optional(
t.Array(
t.Object({
hardwareSlug: t.String(),
isSupported: t.Boolean(),
protonStatus: t.String(),
})
)
),
}),
}
)
+1
View File
@@ -15,3 +15,4 @@ export { adminReportRoutes } from "./admin-reports"
export { adminPerformanceRoutes } from "./admin-performance" export { adminPerformanceRoutes } from "./admin-performance"
export { adminCommentRoutes } from "./admin-comments" export { adminCommentRoutes } from "./admin-comments"
export { steamgridProxyRoutes } from "./steamgrid-proxy" export { steamgridProxyRoutes } from "./steamgrid-proxy"
export { gamesManualRoutes } from "./games-manual"