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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user