From c6d4e7b3d199db12cf69c832ba944607cbdafa3d Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 29 Apr 2026 00:00:10 +0800 Subject: [PATCH] feat: non-Steam game submission wizard with SteamGridDB integration --- app/api/[[...slugs]]/route.ts | 3 + app/game/add/page.tsx | 30 ++ app/search/page.tsx | 14 + components/wizard/non-steam-wizard.tsx | 234 ++++++++++++++++ .../steps/non-steam-basic-info-step.tsx | 242 ++++++++++++++++ .../wizard/steps/non-steam-image-step.tsx | 263 ++++++++++++++++++ .../wizard/steps/non-steam-platform-step.tsx | 223 +++++++++++++++ .../wizard/steps/non-steam-review-step.tsx | 198 +++++++++++++ lib/api/games-manual.ts | 95 +++++++ lib/api/index.ts | 1 + 10 files changed, 1303 insertions(+) create mode 100644 app/game/add/page.tsx create mode 100644 components/wizard/non-steam-wizard.tsx create mode 100644 components/wizard/steps/non-steam-basic-info-step.tsx create mode 100644 components/wizard/steps/non-steam-image-step.tsx create mode 100644 components/wizard/steps/non-steam-platform-step.tsx create mode 100644 components/wizard/steps/non-steam-review-step.tsx create mode 100644 lib/api/games-manual.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index 809d7ee..f96e239 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -23,6 +23,7 @@ import { steamSearchRoutes } from "@/lib/api/steam-search" import { searchUnifiedRoutes } from "@/lib/api/search-unified" import { gameStubRoutes } from "@/lib/api/game-stub" import { gameStatsRoutes } from "@/lib/api/game-stats" +import { gamesManualRoutes } from "@/lib/api/games-manual" import { gamesListingRoutes } from "@/lib/api/games-listing" import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy" @@ -89,6 +90,8 @@ export const app = new Elysia({ prefix: "/api" }) .use(steamgridProxyRoutes) // Game stats aggregation .use(gameStatsRoutes) + // Manual game creation + .use(gamesManualRoutes) // Saved games .use(savedGamesRoutes) // Contact form diff --git a/app/game/add/page.tsx b/app/game/add/page.tsx new file mode 100644 index 0000000..29451be --- /dev/null +++ b/app/game/add/page.tsx @@ -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 ( +
+
+

Add a Game

+

+ Add a non-Steam game to DeckyVault. Search for cover art, set platform support, and submit. +

+
+ +
+ ) +} diff --git a/app/search/page.tsx b/app/search/page.tsx index cbd9729..2961e4b 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -14,6 +14,8 @@ import { } from "lucide-react" import { FaSteam } from "react-icons/fa" import Image from "next/image" +import Link from "next/link" +import { useSession } from "@/lib/auth-client" import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons" interface UnifiedResult { @@ -49,6 +51,7 @@ interface UnifiedResult { function SearchContent() { const searchParams = useSearchParams() const router = useRouter() + const { data: session } = useSession() const query = searchParams.get("q") || "" const [results, setResults] = useState([]) @@ -191,6 +194,17 @@ function SearchContent() { )} + + {session?.user && ( +
+

+ Can't find your game?{" "} + + Add it manually + +

+
+ )} ) diff --git a/components/wizard/non-steam-wizard.tsx b/components/wizard/non-steam-wizard.tsx new file mode 100644 index 0000000..293239e --- /dev/null +++ b/components/wizard/non-steam-wizard.tsx @@ -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(null) + const [success, setSuccess] = useState(false) + const [formData, setFormData] = useState({ + 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 ( + +
+ + + +
+

Game Created!

+

Redirecting to game page...

+
+ ) + } + + return ( +
+ + +
+ * Required fields +
+ + + + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + + + + {/* Navigation Buttons */} + {step < STEPS.length - 1 && ( +
+ + +
+ )} +
+ ) +} diff --git a/components/wizard/steps/non-steam-basic-info-step.tsx b/components/wizard/steps/non-steam-basic-info-step.tsx new file mode 100644 index 0000000..1039b58 --- /dev/null +++ b/components/wizard/steps/non-steam-basic-info-step.tsx @@ -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([]) + 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 ( +
+
+ +
+

Basic Info

+

+ Enter the game details. Title is required. +

+
+
+ + {/* Title */} +
+ +
+ 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 && ( + + )} +
+ {duplicates.length > 0 && ( +
+ +
+ Did you mean:{" "} + {duplicates.map((d, i) => ( + + + {d.title} + + {i < duplicates.length - 1 ? ", " : ""} + + ))} +
+
+ )} +
+ + {/* Source */} +
+ +
+ + + + +
+
+ + {/* Developer / Publisher */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Description */} +
+ +