feat: non-Steam game submission wizard with SteamGridDB integration
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user