"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 (
{label}
) } 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) } 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 */} {file.name} {/* Overlay gradient */}
{/* Drag handle (top-left) */}
{ e.preventDefault(); dragControls.start(e) }} >
{/* Remove button (top-right) */} {/* Index badge */}
#{index + 1}
{/* File info (bottom-right) */}
{formatFileSize(file.size)}
{/* Filename bar */}

{file.name}

) } 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()) const [previewUrls, setPreviewUrls] = useState([]) 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 (

Review & Submit

Review your submission details below. Add any additional notes and click Submit when ready.

{/* Summary Cards */}
{/* Setup: Hardware + Version + Anti-Cheat */}
{antiCheat.antiCheatRelevant && ( <> )} {!antiCheat.antiCheatRelevant && ( )}
{/* 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) ? (
YouTube Video