Files
deckyvault/apps/web/components/wizard/steps/review-step.tsx
T
adrianbonpin 711e80efe1 fix(wizard): remove settings requirement gate for screenshot upload
The submission wizard required at least one game setting category before
allowing screenshot upload ('Add game settings to enable screenshot upload').
Removed that restriction — screenshots can now be attached to any entry
regardless of whether game settings were configured. The Decky plugin entries
typically don't include structured settings, and they should still support
screenshot attachments.
2026-06-29 00:37:18 +08:00

717 lines
29 KiB
TypeScript

"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>
<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>
)
}