feat: add wizard step components
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Terminal, ChevronDown, Loader2 } from "lucide-react"
|
||||
import { api } from "@/lib/eden"
|
||||
|
||||
const FSR_OPTIONS = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "fsr1", label: "FSR 1" },
|
||||
{ value: "fsr2", label: "FSR 2" },
|
||||
{ value: "fsr3", label: "FSR 3" },
|
||||
] as const
|
||||
|
||||
const FRAME_GEN_OPTIONS = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "fsr_fg", label: "FSR Frame Generation" },
|
||||
{ value: "dlss_fg", label: "DLSS Frame Generation" },
|
||||
] as const
|
||||
|
||||
export interface EnvironmentData {
|
||||
protonVersion?: string
|
||||
osVersion?: string
|
||||
fsrVersion?: string
|
||||
frameGenMethod?: string
|
||||
launchOptions?: string
|
||||
}
|
||||
|
||||
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 [inputValue, setInputValue] = useState(value)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value)
|
||||
}, [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 = () => {
|
||||
setOpen(true)
|
||||
fetchSuggestions(inputValue)
|
||||
}
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
setInputValue(val)
|
||||
onChange(val)
|
||||
fetchSuggestions(val)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
setInputValue(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}
|
||||
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">FSR Version</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value.fsrVersion ?? "none"}
|
||||
onChange={(e) => update("fsrVersion", 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"
|
||||
>
|
||||
{FSR_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 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"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">
|
||||
<p className="text-sm text-text/60">
|
||||
Select the hardware device you used to test this game.
|
||||
</p>
|
||||
|
||||
<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,5 @@
|
||||
export { HardwareStep } from "./hardware-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,126 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { Gauge, Timer } from "lucide-react"
|
||||
|
||||
export interface PerformanceData {
|
||||
fpsAvg?: number
|
||||
fpsLow?: number
|
||||
fpsHigh?: number
|
||||
loadTimeSsd?: number
|
||||
loadTimeSd?: 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 && value.fpsAvg <= 0) {
|
||||
return "FPS Average must be greater than 0"
|
||||
}
|
||||
return null
|
||||
}, [value.fpsAvg])
|
||||
|
||||
const update = (field: keyof PerformanceData, val: string) => {
|
||||
const num = val === "" ? undefined : Number(val)
|
||||
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-3 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="number"
|
||||
min={1}
|
||||
step={1}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">FPS Low</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
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="number"
|
||||
min={1}
|
||||
step={1}
|
||||
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="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
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="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText } 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"
|
||||
|
||||
export interface ReviewData {
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
performance: PerformanceData
|
||||
settings: SettingCategory[]
|
||||
environment: EnvironmentData
|
||||
}
|
||||
|
||||
interface ReviewStepProps {
|
||||
data: ReviewData
|
||||
userNotes: string
|
||||
onUserNotesChange: (notes: string) => void
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function formatNumber(val: number | undefined): string {
|
||||
if (val === undefined || val === null) return "Not set"
|
||||
return String(val)
|
||||
}
|
||||
|
||||
export function ReviewStep({
|
||||
data,
|
||||
userNotes,
|
||||
onUserNotesChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
error,
|
||||
}: ReviewStepProps) {
|
||||
const { hardwareName, performance, environment, settings } = data
|
||||
|
||||
const fsrLabel =
|
||||
environment.fsrVersion === "none"
|
||||
? "None"
|
||||
: environment.fsrVersion === "fsr1"
|
||||
? "FSR 1"
|
||||
: environment.fsrVersion === "fsr2"
|
||||
? "FSR 2"
|
||||
: environment.fsrVersion === "fsr3"
|
||||
? "FSR 3"
|
||||
: "Not set"
|
||||
|
||||
const frameGenLabel =
|
||||
environment.frameGenMethod === "none"
|
||||
? "None"
|
||||
: environment.frameGenMethod === "fsr_fg"
|
||||
? "FSR Frame Generation"
|
||||
: environment.frameGenMethod === "dlss_fg"
|
||||
? "DLSS Frame Generation"
|
||||
: "Not set"
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText 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 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">
|
||||
{/* Hardware */}
|
||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
||||
<SectionHeader icon={Monitor} label="Hardware" />
|
||||
<SummaryRow label="Device" value={hardwareName || data.hardwareSlug || "Not selected"} />
|
||||
</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="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)} />
|
||||
</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="FSR Version" value={fsrLabel} />
|
||||
<SummaryRow label="Frame Gen" value={frameGenLabel} />
|
||||
{environment.launchOptions && (
|
||||
<SummaryRow
|
||||
label="Launch Options"
|
||||
value={
|
||||
<span className="font-mono text-[10px] truncate max-w-[160px] block" title={environment.launchOptions}>
|
||||
{environment.launchOptions}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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 flex-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" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user