- {isEmpty && !showCustomInput && (
-
-
No settings configured
-
-
-
-
-
- )}
+ const collapseAll = () => {
+ setCollapsedCategories(new Set(value.map((c) => c.category)))
+ }
- {isEmpty && showCustomInput && (
-
-
-
- setNewCategoryName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault()
- if (newCategoryName.trim()) {
- addCategory()
- setShowCustomInput(false)
- }
+ const expandAll = () => {
+ setCollapsedCategories(new Set())
+ }
+
+ const allCollapsed =
+ value.length > 0 && collapsedCategories.size === value.length
+ const allExpanded = collapsedCategories.size === 0
+
+ const addCategory = () => {
+ const name = newCategoryName.trim()
+ if (!name) return
+ if (value.some((c) => c.category === name)) return
+
+ onChange([...value, { category: name, settings: [] }])
+ setNewCategoryName("")
+ }
+
+ const removeCategory = (category: string) => {
+ onChange(value.filter((c) => c.category !== category))
+ setCollapsedCategories((prev) => {
+ const next = new Set(prev)
+ next.delete(category)
+ return next
+ })
+ }
+
+ const addSetting = (category: string) => {
+ const name = (newSettingNames[category] || "").trim()
+ if (!name) return
+ const type = newSettingTypes[category] || "text"
+ const defaultValue =
+ type === "boolean" ? false : type === "number" ? 0 : ""
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ if (c.settings.some((s) => s.title === name)) return c
+ return {
+ ...c,
+ settings: [
+ ...c.settings,
+ { title: name, value: defaultValue },
+ ],
}
- }}
- placeholder="e.g. Graphics, Audio, Display..."
- className="flex-1 px-4 py-2.5 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"
- />
-
-
-
-
- )}
+ }),
+ )
+ setNewSettingNames((prev) => ({ ...prev, [category]: "" }))
+ // Refocus after React re-renders
+ setTimeout(() => addSettingInputRefs.current[category]?.focus(), 0)
+ }
-
- {value.map((cat, index) => {
- const isCollapsed = collapsedCategories.has(cat.category)
- return (
-
-
-
-
-
-
-
+ const removeSetting = (category: string, settingTitle: string) => {
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ return {
+ ...c,
+ settings: c.settings.filter(
+ (s) => s.title !== settingTitle,
+ ),
+ }
+ }),
+ )
+ }
+
+ const duplicateSetting = (category: string, settingTitle: string) => {
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ const idx = c.settings.findIndex((s) => s.title === settingTitle)
+ if (idx === -1) return c
+ const original = c.settings[idx]
+
+ // Find a unique title
+ let copyTitle = `${original.title} (copy)`
+ let n = 2
+ while (c.settings.some((s) => s.title === copyTitle)) {
+ copyTitle = `${original.title} (copy ${n})`
+ n++
+ }
+
+ const next = [...c.settings]
+ next.splice(idx + 1, 0, {
+ title: copyTitle,
+ value: original.value,
+ })
+ return { ...c, settings: next }
+ }),
+ )
+ }
+
+ const cycleSettingType = (category: string, settingTitle: string) => {
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ return {
+ ...c,
+ settings: c.settings.map((s) => {
+ if (s.title !== settingTitle) return s
+ const meta = getTypeMeta(s.value)
+ return { ...s, value: convertValue(s.value, meta.next) }
+ }),
+ }
+ }),
+ )
+ }
+
+ const updateSetting = (
+ category: string,
+ settingTitle: string,
+ newValue: string | number | boolean,
+ ) => {
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ return {
+ ...c,
+ settings: c.settings.map((s) =>
+ s.title === settingTitle
+ ? { ...s, value: newValue }
+ : s,
+ ),
+ }
+ }),
+ )
+ }
+
+ const handleReorderSettings = (
+ category: string,
+ newSettings: SettingItem[],
+ ) => {
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ return { ...c, settings: newSettings }
+ }),
+ )
+ }
+
+ const parsePaste = (category: string) => {
+ const lines = pasteText.split("\n")
+ const newSettings: SettingItem[] = []
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ // Match: Key = Value, Key: Value, Key - Value
+ const match = trimmed.match(/^(.+?)\s*[:=\-]\s*(.+)$/)
+ if (!match) continue
+ const [, rawKey, rawVal] = match
+ const key = rawKey.trim()
+ const val = rawVal.trim()
+ if (!key || !val) continue
+
+ // Detect type
+ const lower = val.toLowerCase()
+ let parsedValue: string | number | boolean = val
+ if (
+ lower === "true" ||
+ lower === "false" ||
+ lower === "on" ||
+ lower === "off" ||
+ lower === "yes" ||
+ lower === "no"
+ ) {
+ parsedValue =
+ lower === "true" || lower === "on" || lower === "yes"
+ } else if (/^-?\d+$/.test(val)) {
+ parsedValue = Number(val)
+ }
+
+ newSettings.push({ title: key, value: parsedValue })
+ }
+
+ if (newSettings.length === 0) return
+
+ onChange(
+ value.map((c) => {
+ if (c.category !== category) return c
+ const existingTitles = new Set(c.settings.map((s) => s.title))
+ const uniqueNew = newSettings.filter(
+ (s) => !existingTitles.has(s.title),
+ )
+ return {
+ ...c,
+ settings: [...c.settings, ...uniqueNew],
+ }
+ }),
+ )
+
+ setPasteText("")
+ setPasteOpenFor(null)
+ }
+
+ const loadDefaults = () => {
+ onChange(DEFAULT_CATEGORIES)
+ setCollapsedCategories(new Set())
+ }
+
+ const isEmpty = value.length === 0
+ const [showCustomInput, setShowCustomInput] = useState(false)
+ const customInputRef = useRef
(null)
+
+ useEffect(() => {
+ if (showCustomInput && customInputRef.current) {
+ customInputRef.current.focus()
+ }
+ }, [showCustomInput])
+
+ // Keyboard reorder: Alt + ArrowUp/ArrowDown
+ const valueRef = useRef(value)
+ const onChangeRef = useRef(onChange)
+
+ useEffect(() => {
+ valueRef.current = value
+ onChangeRef.current = onChange
+ })
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (!e.altKey) return
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return
+
+ const active = document.activeElement
+ if (!active) return
+
+ const row = active.closest("[data-setting-row]")
+ if (!row) return
+
+ const category = row.getAttribute("data-category")
+ const title = row.getAttribute("data-title")
+ if (!category || !title) return
+
+ e.preventDefault()
+
+ const currentValue = valueRef.current
+ const direction = e.key === "ArrowUp" ? -1 : 1
+
+ const nextValue = currentValue.map((c) => {
+ if (c.category !== category) return c
+ const idx = c.settings.findIndex((s) => s.title === title)
+ if (idx === -1) return c
+ const newIdx = idx + direction
+ if (newIdx < 0 || newIdx >= c.settings.length) return c
+ const nextSettings = [...c.settings]
+ ;[nextSettings[idx], nextSettings[newIdx]] = [
+ nextSettings[newIdx],
+ nextSettings[idx],
+ ]
+ return { ...c, settings: nextSettings }
+ })
+
+ onChangeRef.current(nextValue)
+
+ // Restore focus after React re-renders
+ requestAnimationFrame(() => {
+ const newRow = document.querySelector(
+ `[data-setting-row][data-category="${category}"][data-title="${title}"]`,
+ )
+ const input = newRow?.querySelector(
+ "input, button",
+ ) as HTMLElement | null
+ input?.focus()
+ })
+ }
+
+ window.addEventListener("keydown", handler)
+ return () => window.removeEventListener("keydown", handler)
+ }, [])
+
+ return (
+
+ {isEmpty && !showCustomInput && (
+
+
+ No settings configured
+
+
+
+
+
-
+ )}
-
- {!isCollapsed && (
-
-
- {/* Add setting row at the top */}
-
-
- {typeOptions.map((opt) => (
-
- ))}
-
+ {isEmpty && showCustomInput && (
+
+
+
-
-
- {cat.settings.map((setting) => (
-
-
- {setting.title}
-
-
- {typeof setting.value === "boolean" ? (
-
+
+
+
+ )}
+
+ {/* Global toolbar */}
+ {value.length > 0 && (
+
+
+
+
+ )}
+
+
+
+ {value.map((cat) => {
+ const isCollapsed = collapsedCategories.has(
+ cat.category,
+ )
+ return (
+ toggleCategory(cat.category)}
+ onRemove={() => removeCategory(cat.category)}
+ newSettingNames={newSettingNames}
+ newSettingTypes={newSettingTypes}
+ onNewSettingNameChange={(name) =>
+ setNewSettingNames((prev) => ({
+ ...prev,
+ [cat.category]: name,
+ }))
+ }
+ onNewSettingTypeChange={(type) =>
+ setNewSettingTypes((prev) => ({
+ ...prev,
+ [cat.category]: type,
+ }))
+ }
+ onAddSetting={() => addSetting(cat.category)}
+ setAddSettingInputRef={setAddSettingInputRef}
+ onUpdateSetting={updateSetting}
+ onRemoveSetting={removeSetting}
+ onDuplicateSetting={duplicateSetting}
+ onTypeSwitch={cycleSettingType}
+ onReorderSettings={(newSettings) =>
+ handleReorderSettings(
+ cat.category,
+ newSettings,
+ )
+ }
+ pasteOpen={pasteOpenFor === cat.category}
+ pasteText={pasteText}
+ onPasteTextChange={setPasteText}
+ onTogglePaste={() =>
+ setPasteOpenFor((prev) =>
+ prev === cat.category
+ ? null
+ : cat.category,
+ )
+ }
+ onParsePaste={() => parsePaste(cat.category)}
+ />
+ )
+ })}
+
+
+
+ {!isEmpty && (
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ addCategory()
+ }
+ }}
+ placeholder="New category name..."
+ className="flex-1 px-4 py-2.5 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"
+ />
+
+
+ )}
+
+ )
+}
+
+function CategoryCard({
+ cat,
+ isCollapsed,
+ onToggle,
+ onRemove,
+ newSettingNames,
+ newSettingTypes,
+ onNewSettingNameChange,
+ onNewSettingTypeChange,
+ onAddSetting,
+ setAddSettingInputRef,
+ onUpdateSetting,
+ onRemoveSetting,
+ onDuplicateSetting,
+ onTypeSwitch,
+ onReorderSettings,
+ pasteOpen,
+ pasteText,
+ onPasteTextChange,
+ onTogglePaste,
+ onParsePaste,
+}: {
+ cat: SettingCategory
+ isCollapsed: boolean
+ onToggle: () => void
+ onRemove: () => void
+ newSettingNames: Record
+ newSettingTypes: Record
+ onNewSettingNameChange: (name: string) => void
+ onNewSettingTypeChange: (type: string) => void
+ onAddSetting: () => void
+ setAddSettingInputRef: (
+ category: string,
+ ) => (el: HTMLInputElement | null) => void
+ onUpdateSetting: (
+ cat: string,
+ title: string,
+ val: string | number | boolean,
+ ) => void
+ onRemoveSetting: (cat: string, title: string) => void
+ onDuplicateSetting: (cat: string, title: string) => void
+ onTypeSwitch: (cat: string, title: string) => void
+ onReorderSettings: (newSettings: SettingItem[]) => void
+ pasteOpen: boolean
+ pasteText: string
+ onPasteTextChange: (text: string) => void
+ onTogglePaste: () => void
+ onParsePaste: () => void
+}) {
+ const dragControls = useDragControls()
+
+ return (
+
+ {/* Category Header */}
+
+ {/* Category Drag Handle */}
+
dragControls.start(e)}
+ >
+
+
+
+ {/* Collapse Toggle */}
+
+
+ {/* Title */}
+
+
+ {/* Remove Category */}
+
+
+
+
+ {!isCollapsed && (
+
+
+ {/* Add setting row at the top */}
+
+
+ {typeOptions.map((opt) => (
+
+ ))}
+
+
+ onNewSettingNameChange(e.target.value)
+ }
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ onAddSetting()
+ }
+ }}
+ placeholder="Add setting..."
+ className="flex-1 min-w-0 px-3 py-1.5 rounded-md 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"
+ />
+
+
+
+
+ {/* Bulk paste textarea */}
+
+ {pasteOpen && (
+
+
+
)}
- {setting.value ? "On" : "Off"}
-
- ) : typeof setting.value === "number" ? (
- {
- const cleaned = e.target.value.replace(/[^0-9]/g, "")
- const updated = cleaned === "" ? 0 : Number(cleaned)
- updateSetting(
- cat.category,
- setting.title,
- updated
- )
- }}
- className="flex-1 min-w-0 px-3 py-1.5 rounded-md 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"
- />
- ) : (
- {
- updateSetting(
- cat.category,
- setting.title,
- e.target.value
- )
- }}
- className="flex-1 min-w-0 px-3 py-1.5 rounded-md 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"
- />
+
+
+ {/* Column header โ visually aligns with setting rows */}
+ {cat.settings.length > 0 && (
+
+
+
+ Type
+
+
+ Setting
+
+
+ Value
+
+
+
)}
-
-
-
-
-
-
- ))}
-
-
-
- )}
-
-
- )
- })}
-
+ {/* Settings list with drag-to-reorder */}
+
+
+ {cat.settings.map((setting) => (
+
+ ))}
+
+
- {!isEmpty && (
-
-
setNewCategoryName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault()
- addCategory()
- }
- }}
- placeholder="New category name..."
- className="flex-1 px-4 py-2.5 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"
- />
-
-
- )}
-
- )
+ {cat.settings.length === 0 && (
+
+ No settings yet โ add one above
+
+ )}
+
+
+ )}
+
+
+ )
}
diff --git a/components/wizard/steps/review-step.tsx b/components/wizard/steps/review-step.tsx
index 581f748..fd363d8 100644
--- a/components/wizard/steps/review-step.tsx
+++ b/components/wizard/steps/review-step.tsx
@@ -1,8 +1,22 @@
"use client"
-import { useEffect, useState } from "react"
-import { motion } from "motion/react"
-import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText, ImagePlus, X } from "lucide-react"
+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"
@@ -10,341 +24,649 @@ import type { EnvironmentData } from "./environment-step"
import { UPSCALER_TYPE_OPTIONS, FRAME_GEN_OPTIONS } from "./environment-step"
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
+ 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"
+ 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"
}
-function SectionHeader({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
- return (
-
-
- {label}
-
- )
+function SectionHeader({
+ icon: Icon,
+ label,
+}: {
+ icon: React.ElementType
+ label: string
+}) {
+ return (
+
+
+
+ {label}
+
+
+ )
}
-function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
- return (
-
- {label}
- {value}
-
- )
+function SummaryRow({
+ label,
+ value,
+}: {
+ label: string
+ value: React.ReactNode
+}) {
+ return (
+
+ {label}
+ {value}
+
+ )
}
function formatNumber(val: number | undefined): string {
- if (val === undefined || val === null) return "Not set"
- return String(val)
+ 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 (
+
+ {/* Image */}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ {/* Overlay gradient */}
+
+
+ {/* Drag handle (top-left) */}
+
dragControls.start(e)}
+ >
+
+
+
+ {/* Remove button (top-right) */}
+
+
+ {/* Index badge */}
+
+ #{index + 1}
+
+
+ {/* File info (bottom-right) */}
+
+ {formatFileSize(file.size)}
+
+
+
+ {/* Filename bar */}
+
+
+ )
}
export function ReviewStep({
- data,
- userNotes,
- onUserNotesChange,
- onSubmit,
- isSubmitting,
- error,
- screenshotFiles,
- onScreenshotFilesChange,
- submitPhase,
+ data,
+ userNotes,
+ onUserNotesChange,
+ onSubmit,
+ isSubmitting,
+ error,
+ screenshotFiles,
+ onScreenshotFilesChange,
+ submitPhase,
}: ReviewStepProps) {
- const { hardwareName, gameVersionLabel, antiCheat, performance, environment, settings } = data
+ const {
+ hardwareName,
+ gameVersionLabel,
+ antiCheat,
+ performance,
+ environment,
+ settings,
+ } = data
- const [previews, setPreviews] = useState<{ file: File; url: string }[]>([])
+ // Stable URL mapping so reordering doesn't flicker
+ const urlMapRef = useRef(new Map
())
+ const [previewUrls, setPreviewUrls] = useState([])
+ const [isDragOver, setIsDragOver] = useState(false)
- useEffect(() => {
- if (!screenshotFiles?.length) {
- setPreviews([])
- return
+ 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 next = screenshotFiles.map((file) => ({ file, url: URL.createObjectURL(file) }))
- setPreviews(next)
- return () => {
- next.forEach((p) => URL.revokeObjectURL(p.url))
+
+ const removeFile = (file: File) => {
+ const newFiles = (screenshotFiles || []).filter((f) => f !== file)
+ onScreenshotFilesChange?.(newFiles)
}
- }, [screenshotFiles])
- 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 handleReorder = (newFiles: File[]) => {
+ onScreenshotFilesChange(newFiles)
+ }
- 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 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
+ })()
- return (
-
-
-
-
-
Review & Submit
-
- Review your submission details below. Add any additional notes and click Submit when ready.
-
-
-
+ 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
+ })()
- {/* Summary Cards */}
-
- {/* Setup: Hardware + Version + Anti-Cheat */}
-
-
-
-
- {antiCheat.antiCheatRelevant && (
- <>
-
-
- >
- )}
- {!antiCheat.antiCheatRelevant && (
-
- )}
-
+ const canUploadMore =
+ (!screenshotFiles || screenshotFiles.length < 2) &&
+ submitPhase !== "uploading" &&
+ submitPhase !== "saving"
- {/* Performance */}
-
-
-
-
-
-
-
-
-
- {
- 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"
- })()}
- />
-
-
- {/* Environment */}
-
-
-
-
-
-
-
- {environment.launchOptions && (
-
- {environment.launchOptions}
-
- }
- />
- )}
- {environment.youtubeVideoId && /^[a-zA-Z0-9_-]{11}$/.test(environment.youtubeVideoId) ? (
-
- ) : (
-
- )}
-
-
- {/* Settings */}
-
-
- {settings.length === 0 ? (
-
No settings configured
- ) : (
-
- {settings.map((cat) => (
-
-
{cat.category}
-
- {cat.settings.map((s) => (
-
- {s.title}: {String(s.value)}
-
- ))}
-
+ return (
+
+
+
+
+
+ Review & Submit
+
+
+ Review your submission details below. Add any additional
+ notes and click Submit when ready.
+
- ))}
- )}
-
-
- {/* Screenshots */}
-
-
- {data.settings.length === 0 ? (
-
-
Add game settings to enable screenshot upload
-
- ) : (
-
- {(submitPhase === "uploading" || submitPhase === "saving") && (
-
-
- Uploading...
-
- )}
-
- {previews.length > 0 && (
-
- {previews.map((preview) => (
-
-

+ {/* Setup: Hardware + Version + Anti-Cheat */}
+
+
-
-
- ))}
-
- )}
+
+
+ {antiCheat.antiCheatRelevant && (
+ <>
+
+
+ >
+ )}
+ {!antiCheat.antiCheatRelevant && (
+
+ )}
+
- {(!screenshotFiles || screenshotFiles.length < 2) && submitPhase !== "uploading" && submitPhase !== "saving" && (
-
+
+ {/* Screenshots */}
+
+
+
+ {(submitPhase === "uploading" ||
+ submitPhase === "saving") && (
+
+
+ Uploading...
+
+ )}
+
+
+ {data.settings.length === 0 ? (
+
+
+ Add game settings to enable screenshot upload
+
+
+ ) : (
+
+ {/* Screenshot grid with drag-to-reorder */}
+ {screenshotFiles && screenshotFiles.length > 0 && (
+
+ {screenshotFiles.map((file, idx) => (
+ removeFile(file)}
+ />
+ ))}
+
+ )}
+
+ {/* Upload area */}
+ {canUploadMore ? (
+
+ ) : (
+
+
+ Maximum 2 screenshots reached
+
+ )}
+
+ )}
+
+
+ {/* User Notes */}
+
+
+ onUserNotesChange(JSON.stringify(json))}
+ placeholder='Add any extra details about your experience...'
/>
-
+
+
+ {/* Error */}
+ {error && (
+
+
+ {error}
+
)}
- {screenshotFiles && screenshotFiles.length >= 2 && (
-
Maximum 2 screenshots reached
- )}
-
-
-
- )}
-
-
- {/* User Notes */}
-
-
- onUserNotesChange(JSON.stringify(json))}
- placeholder="Add any extra details about your experience..."
- />
-
-
- {/* Error */}
- {error && (
-
-
- {error}
-
- )}
-
- {/* Submit Button */}
-
-
- )
+ {/* Submit Button */}
+
+
+ )
}