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