refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
@@ -0,0 +1,614 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import { useRouter } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { StepIndicator } from "@/components/wizard/step-indicator"
import { SetupStep, type GameVersionInfo } from "@/components/wizard/steps/setup-step"
import { type AntiCheatData } from "@/components/wizard/steps/anti-cheat-step"
import { PerformanceStep, type PerformanceData } from "@/components/wizard/steps/performance-step"
import { SettingsStep } from "@/components/wizard/steps/settings-step"
import { EnvironmentStep, type EnvironmentData } from "@/components/wizard/steps/environment-step"
import { ReviewStep, type ExistingScreenshot } from "@/components/wizard/steps/review-step"
import type { SettingCategory } from "@/components/wizard/settings-editor"
import { performanceEntries } from "@/lib/db/schema"
// Export GameVersionInfo so the server page can use it
export type { GameVersionInfo }
const STEPS = [
{ label: "Setup", tooltip: "Choose the hardware, game version, and anti-cheat status" },
{ label: "Performance", tooltip: "Enter the performance metrics you observed. FPS Average is required." },
{ label: "Settings", tooltip: "Configure the game settings you used. Add categories and settings to help others replicate your setup." },
{ label: "Environment", tooltip: "Specify the software environment and any launch options used" },
{ label: "Review", tooltip: "Review your entry before submitting. Add any additional notes." },
]
interface PlatformSupportEntry {
hardwareSlug: string
antiCheatRelevant: boolean
antiCheatName: string | null
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
}
interface GameEntryWizardProps {
gameId: string
gameVersions: GameVersionInfo[]
defaultVersionId: string
editEntry?: typeof performanceEntries.$inferSelect | null
platformSupport: PlatformSupportEntry[]
}
export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEntry, platformSupport }: GameEntryWizardProps) {
const router = useRouter()
const [currentStep, setCurrentStep] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [screenshotFiles, setScreenshotFiles] = useState<File[]>([])
const [submitPhase, setSubmitPhase] = useState<"idle" | "uploading" | "saving" | "success" | "error">("idle")
const [existingScreenshots, setExistingScreenshots] = useState<ExistingScreenshot[]>([])
const [removedScreenshotIds, setRemovedScreenshotIds] = useState<string[]>([])
// Step 0: Setup — Hardware
const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "")
const [hardwareName, setHardwareName] = useState("")
const [hardwareWattHours, setHardwareWattHours] = useState<number | null>(null)
const [hardwareDeviceType, setHardwareDeviceType] = useState<string | null>(null)
// Step 0: Setup — Game Version
const [selectedVersionId, setSelectedVersionId] = useState(defaultVersionId)
const [newVersionString, setNewVersionString] = useState("")
const [isCreatingVersion, setIsCreatingVersion] = useState(false)
// Step 0: Setup — Anti-Cheat
const [antiCheat, setAntiCheat] = useState<AntiCheatData>({
antiCheatRelevant: false,
antiCheatName: "",
antiCheatStatus: "none",
})
// Initialize anti-cheat from existing platformSupport when editing
useEffect(() => {
const entry = platformSupport.find(
(p) => p.hardwareSlug === hardwareSlug && p.antiCheatRelevant
) ?? platformSupport.find((p) => p.antiCheatRelevant)
if (entry) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAntiCheat({
antiCheatRelevant: entry.antiCheatRelevant,
antiCheatName: entry.antiCheatName ?? "",
antiCheatStatus: entry.antiCheatStatus,
})
} else {
setAntiCheat({
antiCheatRelevant: false,
antiCheatName: "",
antiCheatStatus: "none",
})
}
}, [hardwareSlug, platformSupport])
// Step 1: Performance
const [performance, setPerformance] = useState<PerformanceData>(
editEntry
? {
fpsAvg: editEntry.fpsAvg,
fpsOnePercentLow: editEntry.fpsOnePercentLow ?? undefined,
fpsLow: editEntry.fpsLow ?? undefined,
fpsHigh: editEntry.fpsHigh ?? undefined,
loadTimeSsd: editEntry.loadTimeSsd ?? undefined,
loadTimeSd: editEntry.loadTimeSd ?? undefined,
tdpWatts: editEntry.tdpWatts ?? undefined,
}
: {},
)
// Step 2: Settings
const [settingsJson, setSettingsJson] = useState<SettingCategory[]>(
editEntry?.settingsJson ?? [],
)
// Step 3: Environment
const [environment, setEnvironment] = useState<EnvironmentData>(
editEntry
? {
protonVersion: editEntry.protonVersion ?? undefined,
osVersion: editEntry.osVersion ?? undefined,
upscalerType: editEntry.upscalerType ?? "none",
upscalerVersion: editEntry.upscalerVersion ?? undefined,
frameGenMethod: editEntry.frameGenMethod ?? "none",
launchOptions: editEntry.launchOptions ?? undefined,
customSystem: editEntry.customSystem ?? false,
youtubeVideoId: editEntry.youtubeVideoId ?? undefined,
}
: {
upscalerType: "none",
frameGenMethod: "none",
},
)
// Step 4: Notes
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
// Auto-detected version suggestion (multi-strategy)
const [steamdbVersion, setSteamdbVersion] = useState<{
versionString: string | null
buildId: string | null
source?: string
} | null>(null)
const [steamdbLoading, setSteamdbLoading] = useState(false)
const [steamdbError, setSteamdbError] = useState<string | null>(null)
// Fetch hardware name when slug changes
const handleHardwareChange = useCallback(async (slug: string) => {
setHardwareSlug(slug)
if (!slug) {
setHardwareName("")
setHardwareWattHours(null)
setHardwareDeviceType(null)
return
}
try {
const res = await fetch("/api/performance/hardware")
if (res.ok) {
const data = await res.json() as { data: Array<{ slug: string; name: string; deviceType: string; wattHours: number | null; tdpMax: number | null }> }
const device = data.data.find((d) => d.slug === slug)
if (device) {
setHardwareName(device.name)
setHardwareWattHours(device.wattHours ?? null)
setHardwareDeviceType(device.deviceType ?? null)
}
}
} catch {
// ignore
}
}, [])
// Fetch hardware name when in edit mode
useEffect(() => {
if (!editEntry || !hardwareSlug) return
let cancelled = false
async function fetchName() {
try {
const res = await fetch("/api/performance/hardware")
if (res.ok && !cancelled) {
const data = await res.json() as { data: Array<{ slug: string; name: string; deviceType: string; wattHours: number | null; tdpMax: number | null }> }
const device = data.data.find((d) => d.slug === hardwareSlug)
if (device && !cancelled) {
setHardwareName(device.name)
setHardwareWattHours(device.wattHours ?? null)
setHardwareDeviceType(device.deviceType ?? null)
}
}
} catch {
// ignore
}
}
fetchName()
return () => { cancelled = true }
}, [editEntry, hardwareSlug])
// Resolve the version label for display
const getVersionLabel = useCallback(() => {
if (selectedVersionId === "__new__") {
return newVersionString || "New version"
}
if (selectedVersionId === "__steamdb__") {
return steamdbVersion
? steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`
: "SteamDB version"
}
const v = gameVersions.find((v) => v.id === selectedVersionId)
if (!v) return "Unknown"
return v.versionString || (v.buildId ? `Build ${v.buildId}` : "Unknown version")
}, [selectedVersionId, newVersionString, gameVersions, steamdbVersion])
const handleRemoveExistingScreenshot = useCallback((id: string) => {
setExistingScreenshots((prev) => prev.filter((ss) => ss.id !== id))
setRemovedScreenshotIds((prev) => [...prev, id])
}, [])
const fetchSteamDBVersion = useCallback(async () => {
setSteamdbLoading(true)
setSteamdbError(null)
try {
// Step 1: Try server-side strategies first
const res = await fetch(`/api/games/${gameId}/steamdb-version`)
if (!res.ok) return
const data = await res.json()
// If server found something, use it
if (data.versionString || data.buildId) {
setSteamdbVersion({
versionString: data.versionString,
buildId: data.buildId,
source: data.source,
})
}
// Step 2: If server suggests client-side fetch, run client strategies in browser
if (data.needsClientFetch && data.clientStrategies?.length > 0) {
await runClientStrategies(data.clientStrategies)
}
} catch {
// Silently fail — version detection is best-effort
} finally {
setSteamdbLoading(false)
}
}, [gameId])
// Run client-side strategies (uses browser IP to avoid server rate limits)
const runClientStrategies = useCallback(async (strategyNames: string[]) => {
// Dynamic import of client-side fetchers
const { fetchStorePage } = await import("@/lib/version-fetchers/store-page")
const { fetchCommunityHub } = await import("@/lib/version-fetchers/community-hub")
const { fetchStoreApi } = await import("@/lib/version-fetchers/store-api")
const strategyMap: Record<string, (appId: number) => Promise<{ versionString: string | null; buildId: string | null; source: string; success: boolean }>> = {
"Store Page Scrape": fetchStorePage,
"Community Hub Scrape": fetchCommunityHub,
"Store API Heuristic": fetchStoreApi,
}
// We need the steamAppId — get it from a lightweight endpoint or from props
const gameRes = await fetch(`/api/games/${gameId}`)
if (!gameRes.ok) return
const gameData = await gameRes.json()
const steamAppId = gameData.steamAppId
if (!steamAppId) return
// Run all requested client strategies in parallel
const clientResults = await Promise.all(
strategyNames.map(async (name) => {
const fn = strategyMap[name]
if (!fn) return null
try {
const result = await fn(steamAppId)
return result
} catch {
return null
}
}),
)
const validResults = clientResults.filter(Boolean) as Array<{
versionString: string | null
buildId: string | null
source: string
success: boolean
}>
// Merge with server result — send to server for final merge
if (validResults.length > 0) {
try {
const mergeRes = await fetch(`/api/games/${gameId}/fetch-version-client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientResults: validResults }),
})
if (mergeRes.ok) {
const merged = await mergeRes.json()
if (merged.versionString || merged.buildId) {
setSteamdbVersion({
versionString: merged.versionString,
buildId: merged.buildId,
source: merged.source,
})
}
}
} catch {
// If merge fails, use best client result directly
const bestClient = validResults.find((r) => r.versionString) ??
validResults.find((r) => r.buildId)
if (bestClient && (bestClient.versionString || bestClient.buildId)) {
setSteamdbVersion({
versionString: bestClient.versionString,
buildId: bestClient.buildId,
source: bestClient.source,
})
}
}
}
}, [gameId])
// Initialize existing screenshots when editing
useEffect(() => {
if (editEntry && (editEntry as any).screenshots && Array.isArray((editEntry as any).screenshots)) {
setExistingScreenshots(
(editEntry as any).screenshots.map((ss: any) => ({
type: "existing" as const,
id: ss.id,
url: ss.url,
width: ss.width,
height: ss.height,
orderIndex: ss.orderIndex,
}))
)
}
}, [editEntry])
// Fetch auto-detected version on mount (disabled by default — set NEXT_PUBLIC_VERSION_AUTO_FETCH=true to enable)
useEffect(() => {
if (process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true") {
fetchSteamDBVersion()
}
}, [fetchSteamDBVersion])
const canProceed = () => {
switch (currentStep) {
case 0: // Setup
if (hardwareSlug === "") return false
// If new version selected, require version string
if (selectedVersionId === "__new__" && !newVersionString.trim()) return false
// SteamDB option is always valid (data comes from external source)
if (selectedVersionId === "__steamdb__" && !steamdbVersion) return false
return true
case 1: // Performance
return performance.fpsAvg !== undefined && performance.fpsAvg > 0
case 2: // Settings
return true
case 3: // Environment
return true
case 4: // Review
return true
default:
return false
}
}
const handleNext = () => {
if (currentStep < STEPS.length - 1 && canProceed()) {
setCurrentStep(currentStep + 1)
}
}
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleStepClick = (step: number) => {
if (step <= currentStep) {
setCurrentStep(step)
}
}
// Resolve the final version ID — create a new version if needed
const resolveVersionId = async (): Promise<string> => {
if (selectedVersionId !== "__new__" && selectedVersionId !== "__steamdb__") {
return selectedVersionId
}
if (selectedVersionId === "__steamdb__" && steamdbVersion) {
// Create version from SteamDB data
const res = await fetch(`/api/games/${gameId}/versions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionString: steamdbVersion.versionString,
buildId: steamdbVersion.buildId,
isLatest: true,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to create version from SteamDB")
}
const data = await res.json() as { id: string }
return data.id
}
// Create a new version via API
setIsCreatingVersion(true)
try {
const res = await fetch(`/api/games/${gameId}/versions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionString: newVersionString.trim(),
isLatest: false,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to create game version")
}
const data = await res.json() as { id: string }
return data.id
} finally {
setIsCreatingVersion(false)
}
}
const handleSubmit = async () => {
setIsSubmitting(true)
setSubmitPhase("uploading")
setError(null)
try {
const versionId = await resolveVersionId()
const payload = {
versionId,
hardwareSlug,
fpsAvg: Number(performance.fpsAvg),
fpsOnePercentLow: performance.fpsOnePercentLow !== undefined ? Number(performance.fpsOnePercentLow) : null,
fpsLow: performance.fpsLow !== undefined ? Number(performance.fpsLow) : null,
fpsHigh: performance.fpsHigh !== undefined ? Number(performance.fpsHigh) : null,
loadTimeSsd: performance.loadTimeSsd !== undefined ? Number(performance.loadTimeSsd) : null,
loadTimeSd: performance.loadTimeSd !== undefined ? Number(performance.loadTimeSd) : null,
tdpWatts: performance.tdpWatts !== undefined ? Number(performance.tdpWatts) : null,
youtubeVideoId: environment.youtubeVideoId || null,
protonVersion: environment.protonVersion || null,
osVersion: environment.osVersion || null,
upscalerType: environment.upscalerType ?? "none",
upscalerVersion: environment.upscalerVersion || null,
frameGenMethod: environment.frameGenMethod ?? "none",
launchOptions: environment.launchOptions || null,
customSystem: environment.customSystem ?? false,
removedScreenshotIds: removedScreenshotIds.length > 0 ? removedScreenshotIds : undefined,
settingsJson: settingsJson.length > 0 ? settingsJson : null,
userNotes: userNotes || null,
antiCheatRelevant: antiCheat.antiCheatRelevant,
antiCheatName: antiCheat.antiCheatName || null,
antiCheatStatus: antiCheat.antiCheatStatus,
}
const formData = new FormData()
formData.append("payload", JSON.stringify(payload))
for (const file of screenshotFiles) {
formData.append("screenshots", file)
}
setSubmitPhase("saving")
const url = editEntry
? `/api/performance/${editEntry.id}/edit`
: "/api/performance/submit"
const method = editEntry ? "PATCH" : "POST"
const res = await fetch(url, { method, body: formData })
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || `Failed to ${editEntry ? "update" : "submit"} entry`)
}
setSubmitPhase("success")
setTimeout(() => {
router.push(`/game/${gameId}`)
}, 2000)
} catch (err) {
setSubmitPhase("error")
setError(err instanceof Error ? err.message : "An error occurred")
} finally {
setIsSubmitting(false)
}
}
if (submitPhase === "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"
>
<h2 className="text-xl font-bold mb-2">Entry Submitted!</h2>
<p className="text-sm text-text/60">Redirecting to game page...</p>
</motion.div>
)
}
return (
<div className="space-y-8">
{/* Step Indicator */}
<StepIndicator
steps={STEPS}
currentStep={currentStep}
onStepClick={handleStepClick}
/>
<div className="flex items-center gap-1 text-xs text-text/40">
<span className="text-red-400">*</span> Required fields
</div>
{/* Step Content */}
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="min-h-[300px]"
>
{currentStep === 0 && (
<SetupStep
gameId={gameId}
gameVersions={gameVersions}
hardwareSlug={hardwareSlug}
onHardwareChange={handleHardwareChange}
hardwareName={hardwareName}
selectedVersionId={selectedVersionId}
onVersionChange={setSelectedVersionId}
newVersionString={newVersionString}
onNewVersionStringChange={setNewVersionString}
isCreatingVersion={isCreatingVersion}
antiCheat={antiCheat}
onAntiCheatChange={setAntiCheat}
platformSupport={platformSupport}
steamdbVersion={steamdbVersion}
steamdbLoading={steamdbLoading}
steamdbError={steamdbError}
onRefreshSteamDB={fetchSteamDBVersion}
/>
)}
{currentStep === 1 && (
<PerformanceStep value={performance} onChange={setPerformance} />
)}
{currentStep === 2 && (
<SettingsStep value={settingsJson} onChange={setSettingsJson} />
)}
{currentStep === 3 && (
<EnvironmentStep value={environment} onChange={setEnvironment} />
)}
{currentStep === 4 && (
<ReviewStep
data={{
hardwareSlug,
hardwareName,
hardwareWattHours,
hardwareDeviceType,
gameVersionLabel: getVersionLabel(),
antiCheat,
performance,
settings: settingsJson,
environment,
}}
userNotes={userNotes}
onUserNotesChange={setUserNotes}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
error={error}
screenshotFiles={screenshotFiles}
onScreenshotFilesChange={setScreenshotFiles}
submitPhase={submitPhase}
existingScreenshots={existingScreenshots}
onRemoveExistingScreenshot={handleRemoveExistingScreenshot}
/>
)}
</motion.div>
</AnimatePresence>
{/* Navigation Buttons */}
<div className="flex justify-between">
{currentStep > 0 && (
<button
type="button"
onClick={handleBack}
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"
>
Back
</button>
)}
{currentStep < 4 && (
<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 ml-auto"
>
Next
</button>
)}
</div>
</div>
)
}
@@ -0,0 +1,278 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { SettingsIcon, Gamepad2Icon } from "lucide-react"
interface HardwareDevice {
slug: string
name: string
deviceType: string
}
interface PlatformSupportEntry {
hardwareSlug: string
isSupported: boolean
protonStatus: string
}
interface GameData {
id: string
title: string | null
developer: string | null
publisher: string | null
description: string | null
source: string
storeUrl: string | null
headerImage: string | null
capsuleImage: string | null
genres: string[] | null
releaseDate: string | null
createdBy: string | null
}
interface Props {
game: GameData
platformSupport: PlatformSupportEntry[]
hardwareList: HardwareDevice[]
isOwner: boolean
isAdmin: boolean
}
export function NonSteamEditForm({ game, platformSupport, hardwareList, isOwner, isAdmin }: Props) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState(game.title ?? "")
const [developer, setDeveloper] = useState(game.developer ?? "")
const [publisher, setPublisher] = useState(game.publisher ?? "")
const [description, setDescription] = useState(game.description ?? "")
const [storeUrl, setStoreUrl] = useState(game.storeUrl ?? "")
const [headerImage, setHeaderImage] = useState(game.headerImage ?? "")
const [capsuleImage, setCapsuleImage] = useState(game.capsuleImage ?? "")
const [genresStr, setGenresStr] = useState(game.genres?.join(", ") ?? "")
const [releaseDate, setReleaseDate] = useState(game.releaseDate ?? "")
const [platforms, setPlatforms] = useState<PlatformSupportEntry[]>(platformSupport)
const canEdit = isOwner || isAdmin
const handleTogglePlatform = (slug: string) => {
setPlatforms(prev => {
const existing = prev.find(p => p.hardwareSlug === slug)
if (existing) {
return prev.filter(p => p.hardwareSlug !== slug)
}
return [...prev, { hardwareSlug: slug, isSupported: true, protonStatus: "unknown" }]
})
}
const handleProtonChange = (slug: string, protonStatus: string) => {
setPlatforms(prev =>
prev.map(p => p.hardwareSlug === slug ? { ...p, protonStatus } : p)
)
}
const handleSubmit = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/games/${game.id}/manual`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
developer: developer.trim() || null,
publisher: publisher.trim() || null,
description: description.trim() || null,
storeUrl: storeUrl.trim() || null,
headerImage: headerImage.trim() || null,
capsuleImage: capsuleImage.trim() || null,
genres: genresStr.split(",").map(g => g.trim()).filter(Boolean),
releaseDate: releaseDate.trim() || null,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to update game")
}
router.push(`/game/${game.id}`)
router.refresh()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to update game")
} finally {
setLoading(false)
}
}
if (!canEdit) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<SettingsIcon className="h-10 w-10 text-text/20" />
<p className="text-sm text-text/40">Only the creator or an admin can edit this game.</p>
</div>
)
}
return (
<div className="flex flex-col gap-8">
{/* Basic Info */}
<div className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Basic Info</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Title *</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Developer</label>
<input
type="text"
value={developer}
onChange={e => setDeveloper(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Publisher</label>
<input
type="text"
value={publisher}
onChange={e => setPublisher(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Store URL</label>
<input
type="text"
value={storeUrl}
onChange={e => setStoreUrl(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary resize-none"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Genres (comma-separated)</label>
<input
type="text"
value={genresStr}
onChange={e => setGenresStr(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Release Date</label>
<input
type="date"
value={releaseDate}
onChange={e => setReleaseDate(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
</div>
</div>
{/* Images */}
<div className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Images</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Header Image URL</label>
<input
type="text"
value={headerImage}
onChange={e => setHeaderImage(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Capsule Image URL</label>
<input
type="text"
value={capsuleImage}
onChange={e => setCapsuleImage(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
</div>
</div>
{/* Platform Support */}
<div className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Platform Support</h2>
<div className="flex flex-col gap-3">
{hardwareList.map(device => {
const active = platforms.find(p => p.hardwareSlug === device.slug)
return (
<div key={device.slug} className="flex items-center justify-between p-3 rounded-lg border border-border bg-text/3">
<div className="flex items-center gap-3">
<Gamepad2Icon className="h-4 w-4 text-text/50" />
<span className="text-sm font-medium">{device.name}</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePlatform(device.slug)}
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors cursor-pointer ${
active
? "bg-green-500/20 border-green-500/30 text-green-400"
: "border-border text-text/40 hover:text-text/60"
}`}
>
{active ? "Supported" : "Unsupported"}
</button>
{active && (
<select
value={active.protonStatus}
onChange={e => handleProtonChange(device.slug, e.target.value)}
className="text-xs bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary"
>
<option value="unknown">Unknown</option>
<option value="native">Native</option>
<option value="proton">Proton</option>
<option value="unsupported">Unsupported</option>
</select>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<button
onClick={() => router.back()}
className="px-4 py-2 rounded-lg border border-border text-sm text-text/70 hover:bg-text/5 transition-colors cursor-pointer"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || !title.trim()}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors cursor-pointer"
>
{loading ? "Saving..." : "Save Changes"}
</button>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
</div>
)
}
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
"use client"
import { motion } from "motion/react"
import { Check, Info } from "lucide-react"
import { useState } from "react"
interface Step {
label: string
tooltip: string
}
interface StepIndicatorProps {
steps: Step[]
currentStep: number
onStepClick?: (step: number) => void
}
export function StepIndicator({
steps,
currentStep,
onStepClick,
}: StepIndicatorProps) {
const [showTooltip, setShowTooltip] = useState<number | null>(null)
return (
<div className="w-full">
{/* Desktop: horizontal steps */}
<div className="hidden sm:flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = index < currentStep
const isCurrent = index === currentStep
const isClickable = index <= currentStep
return (
<div key={index} className="flex items-center flex-1">
{/* Step circle with tooltip */}
<div className="relative">
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
onMouseEnter={() => setShowTooltip(index)}
onMouseLeave={() => setShowTooltip(null)}
disabled={!isClickable}
className={`relative z-10 flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-all ${
isCompleted
? "bg-primary text-white"
: isCurrent
? "bg-primary/20 text-primary border-2 border-primary"
: "bg-text/10 text-text/40"
} ${isClickable ? "cursor-pointer" : "cursor-default"}`}
>
{isCompleted ? (
<Check className="h-4 w-4" />
) : (
index + 1
)}
</button>
{/* Tooltip */}
{showTooltip === index && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-full mt-2 left-1/2 -translate-x-1/2 z-20 px-3 py-2 rounded-lg bg-[#1a1020] border border-white/10 shadow-lg w-48"
>
<p className="text-xs font-medium text-text mb-1">
{step.label}
</p>
<p className="text-xs text-text/60">{step.tooltip}</p>
</motion.div>
)}
</div>
{/* Step label */}
<span
className={`ml-2 text-xs font-medium ${
isCurrent ? "text-text" : "text-text/40"
}`}
>
{step.label}
</span>
{/* Connector line */}
{index < steps.length - 1 && (
<div className="flex-1 mx-3">
<div className="h-0.5 bg-text/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width: isCompleted ? "100%" : "0%",
}}
className="h-full bg-primary rounded-full"
/>
</div>
</div>
)}
</div>
)
})}
</div>
{/* Mobile: compact step indicator */}
<div className="flex sm:hidden items-center justify-between">
<span className="text-sm font-medium">
Step {currentStep + 1} of {steps.length}
</span>
<div className="relative">
<button
type="button"
onMouseEnter={() => setShowTooltip(currentStep)}
onMouseLeave={() => setShowTooltip(null)}
className="text-text/40 hover:text-text transition-colors cursor-pointer"
>
<Info className="h-4 w-4" />
</button>
{showTooltip === currentStep && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-full mt-2 right-0 z-20 px-3 py-2 rounded-lg bg-[#1a1020] border border-white/10 shadow-lg w-48"
>
<p className="text-xs font-medium text-text mb-1">
{steps[currentStep].label}
</p>
<p className="text-xs text-text/60">
{steps[currentStep].tooltip}
</p>
</motion.div>
)}
</div>
</div>
{/* Mobile: step dots */}
<div className="flex sm:hidden items-center gap-1.5 mt-3">
{steps.map((_, index) => (
<button
key={index}
type="button"
onClick={() => index <= currentStep && onStepClick?.(index)}
className={`h-1.5 rounded-full transition-all cursor-pointer ${
index === currentStep
? "w-6 bg-primary"
: index < currentStep
? "w-1.5 bg-primary/50"
: "w-1.5 bg-text/20"
}`}
/>
))}
</div>
</div>
)
}
@@ -0,0 +1,228 @@
"use client"
import { useState, useEffect } from "react"
import { Shield, ShieldCheck, ShieldX, ShieldQuestion } from "lucide-react"
import { cn } from "@/lib/utils"
export type AntiCheatData = {
antiCheatRelevant: boolean
antiCheatName: string
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
}
interface PlatformSupportEntry {
hardwareSlug: string
antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
antiCheatName: string | null
}
interface AntiCheatStepProps {
hardwareSlug: string
platformSupport: PlatformSupportEntry[]
value: AntiCheatData
onChange: (data: AntiCheatData) => void
}
const statusConfig = {
supported: {
icon: ShieldCheck,
label: "Supported",
color: "border-green-500/30 bg-green-500/10",
textColor: "text-green-400",
message: "Anti-cheat works on Linux/SteamOS. Multiplayer should work.",
},
unsupported: {
icon: ShieldX,
label: "Unsupported",
color: "border-red-500/30 bg-red-500/10",
textColor: "text-red-400",
message: "Anti-cheat does not support Linux/SteamOS. Multiplayer may not work.",
},
unknown: {
icon: ShieldQuestion,
label: "Unknown",
color: "border-yellow-500/30 bg-yellow-500/10",
textColor: "text-yellow-400",
message: "Compatibility is unknown. Multiplayer may or may not work.",
},
none: {
icon: Shield,
label: "None",
color: "border-zinc-500/30 bg-zinc-500/10",
textColor: "text-zinc-400",
message: "No anti-cheat detected.",
},
} as const
export function AntiCheatStep({
hardwareSlug,
platformSupport,
value,
onChange,
}: AntiCheatStepProps) {
const [isEditing] = useState(false)
// Find the best existing entry to prefill:
// Prefer the entry for the currently selected hardware,
// otherwise fall back to any entry with anti-cheat data.
const hardwareEntry = platformSupport.find(
(p) => p.hardwareSlug === hardwareSlug && p.antiCheatRelevant
)
const anyEntry = platformSupport.find((p) => p.antiCheatRelevant)
const existingEntry = hardwareEntry ?? anyEntry
// Prefill once when the component mounts if the current value is the default
useEffect(() => {
if (existingEntry && !isEditing) {
onChange({
antiCheatRelevant: existingEntry.antiCheatRelevant,
antiCheatName: existingEntry.antiCheatName ?? "",
antiCheatStatus: existingEntry.antiCheatStatus,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hardwareSlug]) // re-prefill when hardware changes
const relevant = value.antiCheatRelevant
const currentConfig = statusConfig[value.antiCheatStatus]
const CurrentIcon = currentConfig.icon
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<Shield className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Anti-Cheat</h3>
<p className="text-xs text-text/60 mt-1">
Set the anti-cheat status for this game. This helps others know if multiplayer will work.
</p>
</div>
</div>
{/* Toggle: Has anti-cheat? */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() =>
onChange({
...value,
antiCheatRelevant: !relevant,
antiCheatStatus: !relevant ? "unknown" : "none",
antiCheatName: !relevant ? value.antiCheatName : "",
})
}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
relevant ? "bg-primary" : "bg-text/20"
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
relevant ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
<div className="flex flex-col">
<span className="text-sm text-text">Game uses anti-cheat</span>
<span className="text-xs text-text/50">
{relevant
? "Yes — select the anti-cheat name and compatibility below"
: "No anti-cheat software detected in this game"}
</span>
</div>
</div>
{relevant && (
<div className="space-y-4">
{/* Anti-cheat name */}
<div>
<label className="block text-xs font-medium text-text/70 mb-1.5">
Anti-Cheat Name
</label>
<input
type="text"
value={value.antiCheatName}
onChange={(e) =>
onChange({ ...value, antiCheatName: e.target.value })
}
placeholder="e.g. Easy Anti-Cheat, BattlEye, Ricochet"
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm text-text placeholder:text-text/30 focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Status radios */}
<div>
<label className="block text-xs font-medium text-text/70 mb-2">
Compatibility on Linux / SteamOS
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{(
[
"supported",
"unsupported",
"unknown",
] as const
).map((status) => {
const cfg = statusConfig[status]
const Icon = cfg.icon
const active = value.antiCheatStatus === status
return (
<button
key={status}
type="button"
onClick={() =>
onChange({ ...value, antiCheatStatus: status })
}
className={cn(
"flex items-center gap-2 px-3 py-2.5 rounded-lg border text-left transition-colors",
active
? cfg.color
: "border-border bg-text/[0.02] hover:bg-text/5"
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0",
active ? cfg.textColor : "text-text/30"
)}
/>
<span
className={cn(
"text-xs font-medium",
active ? cfg.textColor : "text-text/60"
)}
>
{cfg.label}
</span>
</button>
)
})}
</div>
</div>
</div>
)}
{/* Preview card */}
<div
className={cn(
"rounded-lg border p-4",
currentConfig.color
)}
>
<div className="flex items-center gap-2">
<CurrentIcon className={cn("h-5 w-5", currentConfig.textColor)} />
<h4 className="font-medium text-sm">
{relevant && value.antiCheatName
? value.antiCheatName
: currentConfig.label}
</h4>
</div>
<p className={cn("mt-2 text-sm", currentConfig.textColor)}>
{currentConfig.message}
</p>
</div>
</div>
)
}
@@ -0,0 +1,313 @@
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import { motion, AnimatePresence } from "motion/react"
import { Terminal, ChevronDown, Loader2, ToggleLeft, ToggleRight } from "lucide-react"
import { api } from "@/lib/eden"
export const UPSCALER_TYPE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "fsr", label: "AMD FSR" },
{ value: "dlss", label: "NVIDIA DLSS" },
{ value: "xess", label: "Intel XeSS" },
{ value: "lsfg", label: "Lossless Scaling FG" },
{ value: "other", label: "Other" },
] as const
export const FRAME_GEN_OPTIONS = [
{ value: "none", label: "None" },
{ value: "fsr_fg", label: "FSR Frame Generation" },
{ value: "dlss_fg", label: "DLSS Frame Generation" },
{ value: "lsfg", label: "Lossless Scaling FG" },
{ value: "other", label: "Other" },
] as const
export interface EnvironmentData {
protonVersion?: string
osVersion?: string
upscalerType?: string
upscalerVersion?: string
frameGenMethod?: string
launchOptions?: string
customSystem?: boolean
youtubeVideoId?: string // NEW
}
interface EnvironmentStepProps {
value: EnvironmentData
onChange: (value: EnvironmentData) => void
}
function AutocompleteInput({
label,
placeholder,
value,
onChange,
field,
}: {
label: string
placeholder: string
value: string
onChange: (val: string) => void
field: "protonVersion" | "osVersion"
}) {
const [suggestions, setSuggestions] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [localValue, setLocalValue] = useState("")
const [isFocused, setIsFocused] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const inputValue = isFocused ? localValue : (value ?? "")
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const fetchSuggestions = useCallback(
async (query: string) => {
try {
setLoading(true)
const res = await api.performance.autocomplete.get({ query: { field } })
if (!res.error && res.data?.data) {
const data = res.data.data
const filtered = query
? data.filter((s) => s.toLowerCase().includes(query.toLowerCase()))
: data
setSuggestions(filtered.slice(0, 8))
}
} catch {
setSuggestions([])
} finally {
setLoading(false)
}
},
[field]
)
const handleFocus = () => {
setLocalValue(value ?? "")
setIsFocused(true)
setOpen(true)
fetchSuggestions(value ?? "")
}
const handleChange = (val: string) => {
setLocalValue(val)
onChange(val)
fetchSuggestions(val)
setOpen(true)
}
const handleSelect = (val: string) => {
setLocalValue(val)
onChange(val)
setOpen(false)
}
return (
<div className="space-y-1.5" ref={containerRef}>
<label className="text-xs font-medium text-text/60">{label}</label>
<div className="relative">
<input
type="text"
value={inputValue}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
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"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-text/30" />
)}
<AnimatePresence>
{open && suggestions.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute z-10 mt-1 w-full max-h-48 overflow-y-auto rounded-lg border border-border bg-[#1a1020] shadow-lg"
>
{suggestions.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(s)}
className="w-full px-4 py-2 text-left text-sm text-text/80 hover:bg-primary/10 hover:text-text transition-colors cursor-pointer"
>
{s}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
export function EnvironmentStep({ value, onChange }: EnvironmentStepProps) {
const update = (field: keyof EnvironmentData, val: string) => {
onChange({ ...value, [field]: val })
}
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<Terminal className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Environment</h3>
<p className="text-xs text-text/60 mt-1">
Describe the software environment used during testing.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<AutocompleteInput
label="Proton Version"
placeholder="e.g. Proton Experimental"
value={value.protonVersion ?? ""}
onChange={(val) => update("protonVersion", val)}
field="protonVersion"
/>
<AutocompleteInput
label="OS Version"
placeholder="e.g. SteamOS 3.5"
value={value.osVersion ?? ""}
onChange={(val) => update("osVersion", val)}
field="osVersion"
/>
</div>
<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">Upscaler Type</label>
<div className="relative">
<select
value={value.upscalerType ?? "none"}
onChange={(e) => update("upscalerType", e.target.value)}
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"
>
{UPSCALER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40 pointer-events-none" />
</div>
</div>
{value.upscalerType && value.upscalerType !== "none" && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Upscaler Version</label>
<input
type="text"
value={value.upscalerVersion ?? ""}
onChange={(e) => update("upscalerVersion", e.target.value)}
placeholder="e.g. 3.1"
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>
<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">Frame Gen Method</label>
<div className="relative">
<select
value={value.frameGenMethod ?? "none"}
onChange={(e) => update("frameGenMethod", e.target.value)}
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"
>
{FRAME_GEN_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40 pointer-events-none" />
</div>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Launch Options</label>
<textarea
value={value.launchOptions ?? ""}
onChange={(e) => update("launchOptions", e.target.value)}
placeholder="e.g. PROTON_USE_WINED3D=1 %command%"
rows={3}
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"
/>
<p className="text-xs text-text/40">
Steam launch options or environment variables used.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">
Custom / Modified System
</label>
<div className="flex items-center gap-3 py-3">
<button
type="button"
onClick={() => onChange({ ...value, customSystem: !value.customSystem })}
className={`flex items-center gap-2 text-sm cursor-pointer transition-colors ${
value.customSystem ? "text-primary" : "text-text/40"
}`}
>
{value.customSystem ? (
<ToggleRight className="h-5 w-5" />
) : (
<ToggleLeft className="h-5 w-5" />
)}
{value.customSystem ? "Yes" : "No"}
</button>
<span className="text-xs text-text/40">
Check if using custom firmware, OS, or mods that affect performance.
</span>
</div>
</div>
</div>
<div className="space-y-1.5 pt-2">
<label className="text-xs font-medium text-text/60">
YouTube Video
<span className="text-xs text-text/40 ml-1">Optional link a gameplay video</span>
</label>
<input
type="text"
value={value.youtubeVideoId ?? ""}
onChange={(e) => {
const val = e.target.value
// Accept full URLs or just the 11-char ID
let videoId = val
const ytMatch = val.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
if (ytMatch) videoId = ytMatch[1]
onChange({ ...value, youtubeVideoId: videoId || undefined })
}}
placeholder="YouTube video ID or URL"
maxLength={200}
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"
/>
{value.youtubeVideoId && !/^[a-zA-Z0-9_-]{11}$/.test(value.youtubeVideoId) && (
<p className="text-xs text-red-400 mt-1">Invalid YouTube video ID (must be 11 characters)</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,149 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { Monitor, Gamepad2, Loader2 } from "lucide-react"
import { api } from "@/lib/eden"
interface HardwareDevice {
slug: string
name: string
deviceType: string
}
interface HardwareStepProps {
value: string
onChange: (slug: string) => void
}
export function HardwareStep({ value, onChange }: HardwareStepProps) {
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 api.performance.hardware.get()
if (cancelled) return
if (res.error) {
setError("Failed to load hardware devices")
setDevices([])
} else {
setDevices(res.data?.data ?? [])
}
} catch {
if (!cancelled) {
setError("Failed to load hardware devices")
setDevices([])
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchHardware()
return () => {
cancelled = true
}
}, [])
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>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<p className="text-sm text-text/60">
Select the hardware device you used to test this game.
</p>
<span className="text-red-400 text-xs">*</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{devices.map((device) => {
const isSelected = device.slug === value
const Icon = device.deviceType === "console" ? Gamepad2 : Monitor
return (
<motion.button
key={device.slug}
type="button"
onClick={() => onChange(device.slug)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`relative flex items-center gap-3 p-4 rounded-lg border transition-colors text-left cursor-pointer ${
isSelected
? "border-primary bg-primary/10 ring-2 ring-primary/50"
: "border-border bg-text/5 hover:bg-text/10 hover:border-border-active"
}`}
>
<div
className={`flex items-center justify-center w-10 h-10 rounded-lg ${
isSelected ? "bg-primary/20 text-primary" : "bg-text/10 text-text/50"
}`}
>
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text truncate">
{device.name}
</p>
<p className="text-xs text-text/40 capitalize">
{device.deviceType}
</p>
</div>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-4 h-4 rounded-full bg-primary flex items-center justify-center flex-shrink-0"
>
<svg className="w-2.5 h-2.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>
</motion.div>
)}
</motion.button>
)
})}
</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>
)
}
@@ -0,0 +1,6 @@
export { HardwareStep } from "./hardware-step"
export { AntiCheatStep } from "./anti-cheat-step"
export { PerformanceStep, type PerformanceData } from "./performance-step"
export { SettingsStep } from "./settings-step"
export { EnvironmentStep, type EnvironmentData } from "./environment-step"
export { ReviewStep, type ReviewData } from "./review-step"
@@ -0,0 +1,241 @@
"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="date"
value={value.releaseDate}
onChange={(e) => update("releaseDate", e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary 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 } 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,175 @@
"use client"
import { useMemo } from "react"
import { Gauge, Timer, Zap } from "lucide-react"
export interface PerformanceData {
fpsAvg?: number
fpsOnePercentLow?: number
fpsLow?: number
fpsHigh?: number
loadTimeSsd?: number
loadTimeSd?: number
tdpWatts?: number
}
interface PerformanceStepProps {
value: PerformanceData
onChange: (value: PerformanceData) => void
}
export function PerformanceStep({ value, onChange }: PerformanceStepProps) {
const error = useMemo(() => {
if (value.fpsAvg !== undefined && value.fpsAvg !== null && (isNaN(value.fpsAvg) || value.fpsAvg <= 0)) {
return "FPS Average must be greater than 0"
}
return null
}, [value.fpsAvg])
const update = (field: keyof PerformanceData, val: string) => {
const isDecimalField = field === "loadTimeSsd" || field === "loadTimeSd"
const cleaned = isDecimalField
? val.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1")
: val.replace(/[^0-9]/g, "")
const num = cleaned === "" || cleaned === "." ? undefined : Number(cleaned)
onChange({ ...value, [field]: num })
}
return (
<div className="space-y-6">
{/* FPS Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-text">Frame Rate</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">
FPS Average <span className="text-red-400">*</span>
</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value.fpsAvg ?? ""}
onChange={(e) => update("fpsAvg", e.target.value)}
placeholder="e.g. 45"
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">Required average framerate during gameplay</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">1% Low FPS</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value.fpsOnePercentLow ?? ""}
onChange={(e) => update("fpsOnePercentLow", e.target.value)}
placeholder="e.g. 32"
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">FPS at the 1st percentile represents worst 1% of frametimes</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">FPS Low</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value.fpsLow ?? ""}
onChange={(e) => update("fpsLow", e.target.value)}
placeholder="e.g. 30"
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">FPS High</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value.fpsHigh ?? ""}
onChange={(e) => update("fpsHigh", e.target.value)}
placeholder="e.g. 60"
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>
{error && (
<p className="text-xs text-red-400">{error}</p>
)}
</div>
{/* Load Time Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Timer className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-text">Load Times</h3>
</div>
<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">Load Time SSD (seconds)</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value.loadTimeSsd ?? ""}
onChange={(e) => update("loadTimeSsd", e.target.value)}
placeholder="e.g. 12.5"
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">Load Time SD Card (seconds)</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value.loadTimeSd ?? ""}
onChange={(e) => update("loadTimeSd", e.target.value)}
placeholder="e.g. 35.0"
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>
</div>
{/* Power Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-text">Power</h3>
</div>
<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">
TDP (Watts)
<span className="text-xs text-text/40 ml-1">Optional thermal design power cap during benchmark</span>
</label>
<input
type="text"
inputMode="decimal"
value={value.tdpWatts ?? ""}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1")
const num = val === "" || val === "." ? undefined : Number(val)
onChange({ ...value, tdpWatts: num })
}}
placeholder="e.g. 10"
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>
</div>
</div>
)
}
@@ -0,0 +1,724 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { motion, Reorder, useDragControls } from "motion/react"
import {
Send,
Loader2,
AlertCircle,
Monitor,
Gauge,
SlidersHorizontal,
Terminal,
FileText,
ImagePlus,
X,
GripVertical,
Upload,
ImageIcon,
} from "lucide-react"
import { TiptapEditor } from "@/components/tiptap-editor"
import type { SettingCategory } from "@/components/wizard/settings-editor"
import type { PerformanceData } from "./performance-step"
import type { EnvironmentData } from "./environment-step"
import { UPSCALER_TYPE_OPTIONS, FRAME_GEN_OPTIONS } from "./environment-step"
export interface ExistingScreenshot {
type: "existing"
id: string
url: string
width: number
height: number
orderIndex: number
}
export interface ReviewData {
hardwareSlug: string
hardwareName: string
hardwareWattHours: number | null
hardwareDeviceType: string | null
gameVersionLabel: string
antiCheat: {
antiCheatRelevant: boolean
antiCheatName: string
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
}
performance: PerformanceData
settings: SettingCategory[]
environment: EnvironmentData
}
interface ReviewStepProps {
data: ReviewData
userNotes: string
onUserNotesChange: (notes: string) => void
onSubmit: () => void
isSubmitting: boolean
error: string | null
screenshotFiles: File[]
onScreenshotFilesChange: (files: File[]) => void
submitPhase: "idle" | "uploading" | "saving" | "success" | "error"
existingScreenshots?: ExistingScreenshot[]
onRemoveExistingScreenshot?: (id: string) => void
}
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>
)
}
function formatNumber(val: number | undefined): string {
if (val === undefined || val === null) return "Not set"
return String(val)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function ScreenshotCard({
file,
url,
index,
onRemove,
}: {
file: File
url: string
index: number
onRemove: () => void
}) {
const dragControls = useDragControls()
return (
<Reorder.Item
value={file}
dragListener={false}
dragControls={dragControls}
as='div'
className='group relative rounded-xl border border-border bg-text/3 overflow-hidden shadow-sm hover:shadow-md hover:border-primary/30 transition-shadow transition-colors transition-[border-color]'
whileDrag={{
scale: 1.02,
boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
zIndex: 20,
}}
>
{/* Image */}
<div className='relative aspect-video'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={file.name}
className='w-full h-full object-cover'
draggable={false}
/>
{/* Overlay gradient */}
<div className='absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity' />
{/* Drag handle (top-left) */}
<div
className='absolute top-2 left-2 p-2 rounded-lg bg-black/50 text-white/80 hover:text-white hover:bg-black/70 backdrop-blur-sm cursor-grab active:cursor-grabbing transition-colors opacity-0 group-hover:opacity-100 select-none touch-none'
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
>
<GripVertical className='h-4 w-4' />
</div>
{/* Remove button (top-right) */}
<button
type='button'
onClick={onRemove}
className='absolute top-2 right-2 p-1.5 rounded-lg bg-black/50 text-white/80 hover:text-white hover:bg-red-500/80 backdrop-blur-sm transition-colors opacity-0 group-hover:opacity-100 cursor-pointer'
title='Remove screenshot'
>
<X className='h-3.5 w-3.5' />
</button>
{/* Index badge */}
<div className='absolute bottom-2 left-2 px-2 py-0.5 rounded-md bg-black/50 backdrop-blur-sm text-[10px] font-medium text-white/90 opacity-0 group-hover:opacity-100 transition-opacity'>
#{index + 1}
</div>
{/* File info (bottom-right) */}
<div className='absolute bottom-2 right-2 px-2 py-0.5 rounded-md bg-black/50 backdrop-blur-sm text-[10px] text-white/70 opacity-0 group-hover:opacity-100 transition-opacity truncate max-w-30'>
{formatFileSize(file.size)}
</div>
</div>
{/* Filename bar */}
<div className='px-3 py-2 border-t border-border/50'>
<p
className='text-[11px] text-text/60 truncate'
title={file.name}
>
{file.name}
</p>
</div>
</Reorder.Item>
)
}
export function ReviewStep({
data,
userNotes,
onUserNotesChange,
onSubmit,
isSubmitting,
error,
screenshotFiles,
onScreenshotFilesChange,
submitPhase,
existingScreenshots,
onRemoveExistingScreenshot,
}: ReviewStepProps) {
const {
hardwareName,
gameVersionLabel,
antiCheat,
performance,
environment,
settings,
} = data
// Stable URL mapping so reordering doesn't flicker
const urlMapRef = useRef(new Map<File, string>())
const [previewUrls, setPreviewUrls] = useState<string[]>([])
const [isDragOver, setIsDragOver] = useState(false)
useEffect(() => {
const map = urlMapRef.current
// Create URLs for new files
for (const file of screenshotFiles) {
if (!map.has(file)) {
map.set(file, URL.createObjectURL(file))
}
}
// Revoke URLs for removed files
for (const [file, url] of Array.from(map.entries())) {
if (!screenshotFiles.includes(file)) {
URL.revokeObjectURL(url)
map.delete(file)
}
}
setPreviewUrls(screenshotFiles.map((f) => map.get(f)!))
}, [screenshotFiles])
useEffect(() => {
const map = urlMapRef.current
return () => {
for (const url of map.values()) {
URL.revokeObjectURL(url)
}
map.clear()
}
}, [])
const handleFiles = (files: FileList | null) => {
if (!files) return
const incoming = Array.from(files).filter((f) =>
/image\/(jpeg|png|webp)/.test(f.type),
)
const current = screenshotFiles || []
const combined = [...current, ...incoming].slice(0, 2)
onScreenshotFilesChange?.(combined)
}
const removeFile = (file: File) => {
const newFiles = (screenshotFiles || []).filter((f) => f !== file)
onScreenshotFilesChange?.(newFiles)
}
const handleReorder = (newFiles: File[]) => {
onScreenshotFilesChange(newFiles)
}
const upscalerLabel = (() => {
if (
!data.environment.upscalerType ||
data.environment.upscalerType === "none"
)
return "None"
const opt = UPSCALER_TYPE_OPTIONS.find(
(o) => o.value === data.environment.upscalerType,
)
const base = opt?.label ?? data.environment.upscalerType
return data.environment.upscalerVersion
? `${base} ${data.environment.upscalerVersion}`
: base
})()
const frameGenLabel = (() => {
if (
!environment.frameGenMethod ||
environment.frameGenMethod === "none"
)
return "None"
const opt = FRAME_GEN_OPTIONS.find(
(o) => o.value === environment.frameGenMethod,
)
return opt?.label ?? environment.frameGenMethod
})()
const totalScreenshots = (existingScreenshots?.length ?? 0) + (screenshotFiles?.length ?? 0)
const canUploadMore =
totalScreenshots < 2 &&
submitPhase !== "uploading" &&
submitPhase !== "saving"
const showExistingRemove = totalScreenshots >= 2
return (
<div className='space-y-6'>
<div className='flex items-start gap-3'>
<FileText className='h-4 w-4 text-primary mt-0.5 shrink-0' />
<div>
<h3 className='text-sm font-semibold text-text'>
Review & Submit
</h3>
<p className='text-xs text-text/60 mt-1'>
Review your submission details below. Add any additional
notes and click Submit when ready.
</p>
</div>
</div>
{/* Summary Cards */}
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{/* Setup: Hardware + Version + Anti-Cheat */}
<div className='rounded-lg border border-border bg-text/5 p-4'>
<SectionHeader
icon={Monitor}
label='Setup'
/>
<SummaryRow
label='Device'
value={
hardwareName || data.hardwareSlug || "Not selected"
}
/>
<SummaryRow
label='Game Version'
value={gameVersionLabel}
/>
{antiCheat.antiCheatRelevant && (
<>
<SummaryRow
label='Anti-Cheat'
value={antiCheat.antiCheatName || "Unknown"}
/>
<SummaryRow
label='Anti-Cheat Status'
value={
antiCheat.antiCheatStatus === "supported"
? "Supported"
: antiCheat.antiCheatStatus ===
"unsupported"
? "Unsupported"
: antiCheat.antiCheatStatus ===
"unknown"
? "Unknown"
: "None"
}
/>
</>
)}
{!antiCheat.antiCheatRelevant && (
<SummaryRow
label='Anti-Cheat'
value='None'
/>
)}
</div>
{/* Performance */}
<div className='rounded-lg border border-border bg-text/5 p-4'>
<SectionHeader
icon={Gauge}
label='Performance'
/>
<SummaryRow
label='FPS Average'
value={formatNumber(performance.fpsAvg)}
/>
<SummaryRow
label='1% Low FPS'
value={formatNumber(performance.fpsOnePercentLow)}
/>
<SummaryRow
label='FPS Low'
value={formatNumber(performance.fpsLow)}
/>
<SummaryRow
label='FPS High'
value={formatNumber(performance.fpsHigh)}
/>
<SummaryRow
label='Load Time SSD'
value={formatNumber(performance.loadTimeSsd)}
/>
<SummaryRow
label='Load Time SD'
value={formatNumber(performance.loadTimeSd)}
/>
<SummaryRow
label='TDP (Watts)'
value={formatNumber(performance.tdpWatts)}
/>
<SummaryRow
label='Est. Battery'
value={(() => {
const wh = data.hardwareWattHours
const tdp = performance.tdpWatts
if (
wh &&
tdp &&
tdp > 0 &&
data.hardwareDeviceType === "handheld"
) {
const hours = wh / tdp
const mins = Math.round(hours * 60)
return `~${hours.toFixed(1)}h (${mins} min)`
}
return "Not available — requires TDP and a handheld device"
})()}
/>
</div>
{/* Environment */}
<div className='rounded-lg border border-border bg-text/5 p-4'>
<SectionHeader
icon={Terminal}
label='Environment'
/>
<SummaryRow
label='Proton Version'
value={environment.protonVersion || "Not set"}
/>
<SummaryRow
label='OS Version'
value={environment.osVersion || "Not set"}
/>
<SummaryRow
label='Upscaler'
value={upscalerLabel}
/>
<SummaryRow
label='Frame Gen'
value={frameGenLabel}
/>
<SummaryRow
label='Custom System'
value={environment.customSystem ? "Yes" : "No"}
/>
{environment.launchOptions && (
<SummaryRow
label='Launch Options'
value={
<span
className='font-mono text-[10px] truncate max-w-40 block'
title={environment.launchOptions}
>
{environment.launchOptions}
</span>
}
/>
)}
{environment.youtubeVideoId &&
/^[a-zA-Z0-9_-]{11}$/.test(environment.youtubeVideoId) ? (
<div className='mt-2'>
<span className='text-xs text-text/50'>
YouTube Video
</span>
<div
className='mt-1 relative'
style={{ paddingBottom: "56.25%" }}
>
<iframe
src={`https://www.youtube-nocookie.com/embed/${environment.youtubeVideoId}`}
className='absolute inset-0 w-full h-full rounded-md'
allow='accelerometer; autoplay; encrypted-media; picture-in-picture'
sandbox='allow-scripts allow-same-origin allow-presentation'
allowFullScreen
loading='lazy'
title='Review: Gameplay Video'
/>
</div>
</div>
) : (
<SummaryRow
label='YouTube Video'
value='Not provided'
/>
)}
</div>
{/* Settings */}
<div className='rounded-lg border border-border bg-text/5 p-4'>
<SectionHeader
icon={SlidersHorizontal}
label='Settings'
/>
{settings.length === 0 ? (
<p className='text-xs text-text/40 py-1'>
No settings configured
</p>
) : (
<div className='space-y-2 max-h-40 overflow-y-auto'>
{settings.map((cat) => (
<div key={cat.category}>
<p className='text-xs font-medium text-text/70'>
{cat.category}
</p>
<div className='flex flex-wrap gap-1 mt-0.5'>
{cat.settings.map((s) => (
<span
key={s.title}
className='inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-text/10 text-text/60'
>
{s.title}: {String(s.value)}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Screenshots */}
<div className='space-y-3 overflow-clip'>
<div className='flex items-center justify-between'>
<label className='text-xs font-medium text-text/60 flex items-center gap-1.5'>
<ImageIcon className='h-3.5 w-3.5 text-text/40' />
Screenshots
<span className='text-[10px] text-text/30 font-normal'>
({screenshotFiles?.length ?? 0}/2)
</span>
</label>
{(submitPhase === "uploading" ||
submitPhase === "saving") && (
<div className='flex items-center gap-2 text-xs text-text/60'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Uploading...</span>
</div>
)}
</div>
{data.settings.length === 0 ? (
<div className='rounded-lg border border-border bg-text/5 p-4'>
<p className='text-xs text-text/50'>
Add game settings to enable screenshot upload
</p>
</div>
) : (
<div className='space-y-3'>
{/* Existing screenshots (from edit mode) */}
{existingScreenshots && existingScreenshots.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] text-text/30 uppercase tracking-wider">Existing screenshots</p>
<div className="grid grid-cols-2 gap-3">
{existingScreenshots.map((ss) => (
<div
key={ss.id}
className="group relative rounded-xl border border-border bg-text/3 overflow-hidden"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ss.url}
alt={`Screenshot ${ss.orderIndex + 1}`}
className="w-full aspect-video object-cover"
draggable={false}
/>
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
{showExistingRemove && (
<button
type="button"
onClick={() => onRemoveExistingScreenshot?.(ss.id)}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/50 text-white/80 hover:text-white hover:bg-red-500/80 backdrop-blur-sm transition-colors opacity-0 group-hover:opacity-100 cursor-pointer"
title="Remove screenshot"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-md bg-black/50 backdrop-blur-sm text-[10px] font-medium text-white/90 opacity-0 group-hover:opacity-100">
Existing · #{ss.orderIndex + 1}
</div>
</div>
))}
</div>
</div>
)}
{/* Screenshot grid with drag-to-reorder */}
{screenshotFiles && screenshotFiles.length > 0 && (
<Reorder.Group
axis='x'
values={screenshotFiles}
onReorder={handleReorder}
as='div'
className='grid grid-cols-2 gap-3'
>
{screenshotFiles.map((file, idx) => (
<ScreenshotCard
key={
file.name +
file.size +
file.lastModified
}
file={file}
url={previewUrls[idx] || ""}
index={idx}
onRemove={() => removeFile(file)}
/>
))}
</Reorder.Group>
)}
{/* Upload area */}
{canUploadMore ? (
<label
className={`relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 transition-all cursor-pointer ${
isDragOver
? "border-primary bg-primary/5"
: "border-border bg-text/2 hover:bg-text/5 hover:border-text/20"
}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragOver(true)
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragOver(false)
handleFiles(e.dataTransfer.files)
}}
>
<div
className={`p-2.5 rounded-full transition-colors ${
isDragOver
? "bg-primary/15 text-primary"
: "bg-text/5 text-text/30"
}`}
>
<Upload
className={`h-5 w-5 transition-transform ${
isDragOver ? "scale-110" : ""
}`}
/>
</div>
<div className='text-center space-y-0.5'>
<p className='text-xs text-text/60 font-medium'>
{isDragOver
? "Drop screenshots here"
: screenshotFiles &&
screenshotFiles.length > 0
? "Add another screenshot"
: "Add screenshots"}
</p>
<p className='text-[10px] text-text/30'>
JPG, PNG, WebP · Max 2 files · Drag to
upload
</p>
</div>
<input
type='file'
accept='image/jpeg,image/png,image/webp'
multiple
onChange={(e) => {
handleFiles(e.target.files)
e.target.value = ""
}}
className='sr-only'
/>
</label>
) : (
<div className='flex items-center justify-center gap-2 rounded-xl border border-border bg-text/2 p-4 text-xs text-text/40'>
<ImagePlus className='h-4 w-4 text-text/20' />
Maximum 2 screenshots reached
</div>
)}
</div>
)}
</div>
{/* User Notes */}
<div className='space-y-2'>
<label className='text-xs font-medium text-text/60'>
Additional Notes
</label>
<TiptapEditor
content={userNotes}
onChange={(json) => onUserNotesChange(JSON.stringify(json))}
placeholder='Add any extra details about your experience...'
/>
</div>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className='flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3'
>
<AlertCircle className='h-4 w-4 text-red-400 shrink-0' />
<p className='text-xs text-red-400'>{error}</p>
</motion.div>
)}
{/* Submit Button */}
<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 ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
{submitPhase === "uploading"
? "Uploading screenshots..."
: submitPhase === "saving"
? "Saving entry..."
: "Processing..."}
</>
) : (
<>
<Send className='h-4 w-4' />
Submit Entry
</>
)}
</button>
</div>
)
}
@@ -0,0 +1,27 @@
"use client"
import { SettingsEditor, type SettingCategory } from "@/components/wizard/settings-editor"
import { SlidersHorizontal } from "lucide-react"
interface SettingsStepProps {
value: SettingCategory[]
onChange: (value: SettingCategory[]) => void
}
export function SettingsStep({ value, onChange }: SettingsStepProps) {
return (
<div className="space-y-4">
<div className="flex items-start gap-3">
<SlidersHorizontal className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-text">Game Settings</h3>
<p className="text-xs text-text/60 mt-1">
Configure the in-game settings you used during testing. Add categories and settings as needed, or load defaults to get started.
</p>
</div>
</div>
<SettingsEditor value={value} onChange={onChange} />
</div>
)
}
@@ -0,0 +1,203 @@
"use client"
import { GitBranch, DatabaseIcon, RefreshCwIcon } from "lucide-react"
import { HardwareStep } from "./hardware-step"
import { AntiCheatStep, type AntiCheatData } from "./anti-cheat-step"
export interface GameVersionInfo {
id: string
versionString: string | null
buildId: string | null
isLatest: boolean
}
export interface SteamDBVersion {
versionString: string | null
buildId: string | null
source?: string
}
interface SetupStepProps {
gameId: string
gameVersions: GameVersionInfo[]
hardwareSlug: string
onHardwareChange: (slug: string) => void
hardwareName: string
selectedVersionId: string
onVersionChange: (versionId: string) => void
newVersionString: string
onNewVersionStringChange: (value: string) => void
isCreatingVersion: boolean
antiCheat: AntiCheatData
onAntiCheatChange: (data: AntiCheatData) => void
platformSupport: {
hardwareSlug: string
antiCheatRelevant: boolean
antiCheatName: string | null
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
}[]
steamdbVersion: SteamDBVersion | null
steamdbLoading: boolean
steamdbError: string | null
onRefreshSteamDB: () => void
}
export function SetupStep({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
gameId: _gameId,
gameVersions,
hardwareSlug,
onHardwareChange,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hardwareName: _hardwareName,
selectedVersionId,
onVersionChange,
newVersionString,
onNewVersionStringChange,
isCreatingVersion,
antiCheat,
onAntiCheatChange,
platformSupport,
steamdbVersion,
steamdbLoading,
steamdbError,
onRefreshSteamDB,
}: SetupStepProps) {
const isNewVersion = selectedVersionId === "__new__"
const isSteamDBVersion = selectedVersionId === "__steamdb__"
const autoFetchEnabled = process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true"
return (
<div className="space-y-8">
{/* Hardware Section */}
<section>
<HardwareStep value={hardwareSlug} onChange={onHardwareChange} />
</section>
<div className="border-t border-border" />
{/* Game Version Section */}
<section>
<div className="flex items-center gap-2 mb-4">
<GitBranch className="h-4 w-4 text-primary" />
<div>
<h3 className="text-sm font-semibold text-text">Game Version</h3>
<p className="text-xs text-text/60">
Which version of the game did you test? This helps others know if benchmarks match their version.
</p>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Version</label>
<select
value={selectedVersionId}
onChange={(e) => onVersionChange(e.target.value)}
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"
>
{/* Auto-detected version suggestion — only when feature is enabled */}
{autoFetchEnabled && steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
<option value="__steamdb__" className="bg-primary/10 text-primary">
Latest ({steamdbVersion.source ?? "auto-detected"}): {steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`} recommended
</option>
)}
{autoFetchEnabled && steamdbLoading && (
<option disabled className="text-text/40">
Fetching latest version from SteamDB...
</option>
)}
<option disabled className="text-text/30 text-xs">
Existing versions
</option>
{gameVersions.map((v) => (
<option key={v.id} value={v.id}>
{v.versionString
? v.versionString
: v.buildId
? `Build ${v.buildId}`
: "Unknown version"}
{v.isLatest ? " (latest)" : ""}
</option>
))}
<option value="__new__">
New version...
</option>
</select>
{/* Refresh button for auto-detection (only shown when feature enabled) */}
{autoFetchEnabled && (
<button
type="button"
onClick={onRefreshSteamDB}
disabled={steamdbLoading}
className="flex items-center gap-1 text-xs text-text/40 hover:text-primary transition-colors cursor-pointer mt-1 disabled:opacity-30"
>
<RefreshCwIcon className={`h-3 w-3 ${steamdbLoading ? "animate-spin" : ""}`} />
Refresh auto-detected version
</button>
)}
{steamdbError && (
<p className="text-xs text-red-400 mt-1">{steamdbError}</p>
)}
</div>
{isNewVersion && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">
Version String <span className="text-red-400">*</span>
</label>
<input
type="text"
value={newVersionString}
onChange={(e) => onNewVersionStringChange(e.target.value)}
placeholder="e.g. 1.2.3, Patch 4.0, Hotfix Jan 2025"
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"
disabled={isCreatingVersion}
/>
<p className="text-xs text-text/40">
Enter the game version you tested. This will create a new version entry.
</p>
</div>
)}
{autoFetchEnabled && isSteamDBVersion && steamdbVersion && (
<div className="space-y-3 p-3 rounded-lg border border-primary/30 bg-primary/5">
<div className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-primary" />
<p className="text-xs font-medium text-primary">
Auto-Detected Version ({steamdbVersion.source ?? "unknown source"})
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-text/40">Version</p>
<p className="text-sm text-text">{steamdbVersion.versionString || "—"}</p>
</div>
<div>
<p className="text-xs text-text/40">Build ID</p>
<p className="text-sm text-text font-mono">{steamdbVersion.buildId || "—"}</p>
</div>
</div>
<p className="text-xs text-text/40">
This version will be created when you submit your benchmark.
</p>
</div>
)}
</div>
</section>
<div className="border-t border-border" />
{/* Anti-Cheat Section */}
<section>
<AntiCheatStep
hardwareSlug={hardwareSlug}
platformSupport={platformSupport}
value={antiCheat}
onChange={onAntiCheatChange}
/>
</section>
</div>
)
}