refactor: overhaul settings editor, review step, and preset detail modal
- Refactored settings-editor.tsx with improved structure - Refactored review-step.tsx for better readability - Refactored preset-detail-modal.tsx - Minor fix in game-page-client.tsx
This commit is contained in:
@@ -883,7 +883,7 @@ export function GamePageClient({
|
|||||||
{/* Desktop hero — side-by-side layout */}
|
{/* Desktop hero — side-by-side layout */}
|
||||||
<div className='hidden md:flex gap-4 sm:gap-6 px-[10svw]'>
|
<div className='hidden md:flex gap-4 sm:gap-6 px-[10svw]'>
|
||||||
{/* Cover image */}
|
{/* Cover image */}
|
||||||
<div className='relative shrink-0 aspect-2/3 w-20 sm:w-28 md:w-36 rounded-lg overflow-hidden border border-border bg-text/5 h-max'>
|
<div className='relative shrink-0 aspect-2/3 w-20 sm:w-32 md:w-52 rounded-lg overflow-hidden border border-border bg-text/5 h-max'>
|
||||||
{coverImage && !imgError ? (
|
{coverImage && !imgError ? (
|
||||||
<Image
|
<Image
|
||||||
src={coverImage}
|
src={coverImage}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { motion } from "motion/react"
|
import { motion, Reorder, useDragControls } from "motion/react"
|
||||||
import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText, ImagePlus, X } from "lucide-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 { TiptapEditor } from "@/components/tiptap-editor"
|
||||||
import type { SettingCategory } from "@/components/wizard/settings-editor"
|
import type { SettingCategory } from "@/components/wizard/settings-editor"
|
||||||
import type { PerformanceData } from "./performance-step"
|
import type { PerformanceData } from "./performance-step"
|
||||||
@@ -37,20 +51,34 @@ interface ReviewStepProps {
|
|||||||
submitPhase: "idle" | "uploading" | "saving" | "success" | "error"
|
submitPhase: "idle" | "uploading" | "saving" | "success" | "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
|
function SectionHeader({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className='flex items-center gap-2 mb-2'>
|
||||||
<Icon className="h-3.5 w-3.5 text-primary" />
|
<Icon className='h-3.5 w-3.5 text-primary' />
|
||||||
<span className="text-xs font-semibold text-text/80 uppercase tracking-wider">{label}</span>
|
<span className='text-xs font-semibold text-text/80 uppercase tracking-wider'>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50 last:border-b-0">
|
<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/50'>{label}</span>
|
||||||
<span className="text-xs text-text font-medium">{value}</span>
|
<span className='text-xs text-text font-medium'>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -60,6 +88,93 @@ function formatNumber(val: number | undefined): string {
|
|||||||
return String(val)
|
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-all'
|
||||||
|
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-1.5 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'
|
||||||
|
onPointerDown={(e) => dragControls.start(e)}
|
||||||
|
>
|
||||||
|
<GripVertical className='h-3.5 w-3.5' />
|
||||||
|
</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({
|
export function ReviewStep({
|
||||||
data,
|
data,
|
||||||
userNotes,
|
userNotes,
|
||||||
@@ -71,85 +186,210 @@ export function ReviewStep({
|
|||||||
onScreenshotFilesChange,
|
onScreenshotFilesChange,
|
||||||
submitPhase,
|
submitPhase,
|
||||||
}: ReviewStepProps) {
|
}: 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<File, string>())
|
||||||
|
const [previewUrls, setPreviewUrls] = useState<string[]>([])
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!screenshotFiles?.length) {
|
const map = urlMapRef.current
|
||||||
setPreviews([])
|
|
||||||
return
|
// Create URLs for new files
|
||||||
|
for (const file of screenshotFiles) {
|
||||||
|
if (!map.has(file)) {
|
||||||
|
map.set(file, URL.createObjectURL(file))
|
||||||
}
|
}
|
||||||
const next = screenshotFiles.map((file) => ({ file, url: URL.createObjectURL(file) }))
|
|
||||||
setPreviews(next)
|
|
||||||
return () => {
|
|
||||||
next.forEach((p) => URL.revokeObjectURL(p.url))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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])
|
}, [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 = (() => {
|
const upscalerLabel = (() => {
|
||||||
if (!data.environment.upscalerType || data.environment.upscalerType === "none") return "None"
|
if (
|
||||||
const opt = UPSCALER_TYPE_OPTIONS.find(o => o.value === data.environment.upscalerType)
|
!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
|
const base = opt?.label ?? data.environment.upscalerType
|
||||||
return data.environment.upscalerVersion ? `${base} ${data.environment.upscalerVersion}` : base
|
return data.environment.upscalerVersion
|
||||||
|
? `${base} ${data.environment.upscalerVersion}`
|
||||||
|
: base
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const frameGenLabel = (() => {
|
const frameGenLabel = (() => {
|
||||||
if (!environment.frameGenMethod || environment.frameGenMethod === "none") return "None"
|
if (
|
||||||
const opt = FRAME_GEN_OPTIONS.find(o => o.value === environment.frameGenMethod)
|
!environment.frameGenMethod ||
|
||||||
|
environment.frameGenMethod === "none"
|
||||||
|
)
|
||||||
|
return "None"
|
||||||
|
const opt = FRAME_GEN_OPTIONS.find(
|
||||||
|
(o) => o.value === environment.frameGenMethod,
|
||||||
|
)
|
||||||
return opt?.label ?? environment.frameGenMethod
|
return opt?.label ?? environment.frameGenMethod
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const canUploadMore =
|
||||||
|
(!screenshotFiles || screenshotFiles.length < 2) &&
|
||||||
|
submitPhase !== "uploading" &&
|
||||||
|
submitPhase !== "saving"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className='space-y-6'>
|
||||||
<div className="flex items-start gap-3">
|
<div className='flex items-start gap-3'>
|
||||||
<FileText className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
<FileText className='h-4 w-4 text-primary mt-0.5 shrink-0' />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text">Review & Submit</h3>
|
<h3 className='text-sm font-semibold text-text'>
|
||||||
<p className="text-xs text-text/60 mt-1">
|
Review & Submit
|
||||||
Review your submission details below. Add any additional notes and click Submit when ready.
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||||
{/* Setup: Hardware + Version + Anti-Cheat */}
|
{/* Setup: Hardware + Version + Anti-Cheat */}
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||||
<SectionHeader icon={Monitor} label="Setup" />
|
<SectionHeader
|
||||||
<SummaryRow label="Device" value={hardwareName || data.hardwareSlug || "Not selected"} />
|
icon={Monitor}
|
||||||
<SummaryRow label="Game Version" value={gameVersionLabel} />
|
label='Setup'
|
||||||
|
/>
|
||||||
|
<SummaryRow
|
||||||
|
label='Device'
|
||||||
|
value={
|
||||||
|
hardwareName || data.hardwareSlug || "Not selected"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryRow
|
||||||
|
label='Game Version'
|
||||||
|
value={gameVersionLabel}
|
||||||
|
/>
|
||||||
{antiCheat.antiCheatRelevant && (
|
{antiCheat.antiCheatRelevant && (
|
||||||
<>
|
<>
|
||||||
<SummaryRow label="Anti-Cheat" value={antiCheat.antiCheatName || "Unknown"} />
|
<SummaryRow
|
||||||
<SummaryRow label="Anti-Cheat Status" value={
|
label='Anti-Cheat'
|
||||||
antiCheat.antiCheatStatus === "supported" ? "Supported" :
|
value={antiCheat.antiCheatName || "Unknown"}
|
||||||
antiCheat.antiCheatStatus === "unsupported" ? "Unsupported" :
|
/>
|
||||||
antiCheat.antiCheatStatus === "unknown" ? "Unknown" : "None"
|
<SummaryRow
|
||||||
} />
|
label='Anti-Cheat Status'
|
||||||
|
value={
|
||||||
|
antiCheat.antiCheatStatus === "supported"
|
||||||
|
? "Supported"
|
||||||
|
: antiCheat.antiCheatStatus ===
|
||||||
|
"unsupported"
|
||||||
|
? "Unsupported"
|
||||||
|
: antiCheat.antiCheatStatus ===
|
||||||
|
"unknown"
|
||||||
|
? "Unknown"
|
||||||
|
: "None"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!antiCheat.antiCheatRelevant && (
|
{!antiCheat.antiCheatRelevant && (
|
||||||
<SummaryRow label="Anti-Cheat" value="None" />
|
<SummaryRow
|
||||||
|
label='Anti-Cheat'
|
||||||
|
value='None'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance */}
|
{/* Performance */}
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||||
<SectionHeader icon={Gauge} label="Performance" />
|
<SectionHeader
|
||||||
<SummaryRow label="FPS Average" value={formatNumber(performance.fpsAvg)} />
|
icon={Gauge}
|
||||||
<SummaryRow label="1% Low FPS" value={formatNumber(performance.fpsOnePercentLow)} />
|
label='Performance'
|
||||||
<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
|
<SummaryRow
|
||||||
label="Est. Battery"
|
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={(() => {
|
value={(() => {
|
||||||
const wh = data.hardwareWattHours
|
const wh = data.hardwareWattHours
|
||||||
const tdp = performance.tdpWatts
|
const tdp = performance.tdpWatts
|
||||||
if (wh && tdp && tdp > 0 && data.hardwareDeviceType === "handheld") {
|
if (
|
||||||
|
wh &&
|
||||||
|
tdp &&
|
||||||
|
tdp > 0 &&
|
||||||
|
data.hardwareDeviceType === "handheld"
|
||||||
|
) {
|
||||||
const hours = wh / tdp
|
const hours = wh / tdp
|
||||||
const mins = Math.round(hours * 60)
|
const mins = Math.round(hours * 60)
|
||||||
return `~${hours.toFixed(1)}h (${mins} min)`
|
return `~${hours.toFixed(1)}h (${mins} min)`
|
||||||
@@ -160,58 +400,95 @@ export function ReviewStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environment */}
|
{/* Environment */}
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||||
<SectionHeader icon={Terminal} label="Environment" />
|
<SectionHeader
|
||||||
<SummaryRow label="Proton Version" value={environment.protonVersion || "Not set"} />
|
icon={Terminal}
|
||||||
<SummaryRow label="OS Version" value={environment.osVersion || "Not set"} />
|
label='Environment'
|
||||||
<SummaryRow label="Upscaler" value={upscalerLabel} />
|
/>
|
||||||
<SummaryRow label="Frame Gen" value={frameGenLabel} />
|
<SummaryRow
|
||||||
<SummaryRow label="Custom System" value={environment.customSystem ? "Yes" : "No"} />
|
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 && (
|
{environment.launchOptions && (
|
||||||
<SummaryRow
|
<SummaryRow
|
||||||
label="Launch Options"
|
label='Launch Options'
|
||||||
value={
|
value={
|
||||||
<span className="font-mono text-[10px] truncate max-w-[160px] block" title={environment.launchOptions}>
|
<span
|
||||||
|
className='font-mono text-[10px] truncate max-w-40 block'
|
||||||
|
title={environment.launchOptions}
|
||||||
|
>
|
||||||
{environment.launchOptions}
|
{environment.launchOptions}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{environment.youtubeVideoId && /^[a-zA-Z0-9_-]{11}$/.test(environment.youtubeVideoId) ? (
|
{environment.youtubeVideoId &&
|
||||||
<div className="mt-2">
|
/^[a-zA-Z0-9_-]{11}$/.test(environment.youtubeVideoId) ? (
|
||||||
<span className="text-xs text-text/50">YouTube Video</span>
|
<div className='mt-2'>
|
||||||
<div className="mt-1 relative" style={{ paddingBottom: "56.25%" }}>
|
<span className='text-xs text-text/50'>
|
||||||
|
YouTube Video
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className='mt-1 relative'
|
||||||
|
style={{ paddingBottom: "56.25%" }}
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
src={`https://www.youtube-nocookie.com/embed/${environment.youtubeVideoId}`}
|
src={`https://www.youtube-nocookie.com/embed/${environment.youtubeVideoId}`}
|
||||||
className="absolute inset-0 w-full h-full rounded-md"
|
className='absolute inset-0 w-full h-full rounded-md'
|
||||||
allow="accelerometer; autoplay; encrypted-media; picture-in-picture"
|
allow='accelerometer; autoplay; encrypted-media; picture-in-picture'
|
||||||
sandbox="allow-scripts allow-same-origin allow-presentation"
|
sandbox='allow-scripts allow-same-origin allow-presentation'
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
loading="lazy"
|
loading='lazy'
|
||||||
title="Review: Gameplay Video"
|
title='Review: Gameplay Video'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SummaryRow label="YouTube Video" value="Not provided" />
|
<SummaryRow
|
||||||
|
label='YouTube Video'
|
||||||
|
value='Not provided'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||||
<SectionHeader icon={SlidersHorizontal} label="Settings" />
|
<SectionHeader
|
||||||
|
icon={SlidersHorizontal}
|
||||||
|
label='Settings'
|
||||||
|
/>
|
||||||
{settings.length === 0 ? (
|
{settings.length === 0 ? (
|
||||||
<p className="text-xs text-text/40 py-1">No settings configured</p>
|
<p className='text-xs text-text/40 py-1'>
|
||||||
|
No settings configured
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
<div className='space-y-2 max-h-40 overflow-y-auto'>
|
||||||
{settings.map((cat) => (
|
{settings.map((cat) => (
|
||||||
<div key={cat.category}>
|
<div key={cat.category}>
|
||||||
<p className="text-xs font-medium text-text/70">{cat.category}</p>
|
<p className='text-xs font-medium text-text/70'>
|
||||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
{cat.category}
|
||||||
|
</p>
|
||||||
|
<div className='flex flex-wrap gap-1 mt-0.5'>
|
||||||
{cat.settings.map((s) => (
|
{cat.settings.map((s) => (
|
||||||
<span
|
<span
|
||||||
key={s.title}
|
key={s.title}
|
||||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-text/10 text-text/60"
|
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)}
|
{s.title}: {String(s.value)}
|
||||||
</span>
|
</span>
|
||||||
@@ -225,88 +502,133 @@ export function ReviewStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Screenshots */}
|
{/* Screenshots */}
|
||||||
<div className="space-y-2">
|
<div className='space-y-3 overflow-clip'>
|
||||||
<label className="text-xs font-medium text-text/60">Screenshots</label>
|
<div className='flex items-center justify-between'>
|
||||||
{data.settings.length === 0 ? (
|
<label className='text-xs font-medium text-text/60 flex items-center gap-1.5'>
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
<ImageIcon className='h-3.5 w-3.5 text-text/40' />
|
||||||
<p className="text-xs text-text/50">Add game settings to enable screenshot upload</p>
|
Screenshots
|
||||||
</div>
|
<span className='text-[10px] text-text/30 font-normal'>
|
||||||
) : (
|
({screenshotFiles?.length ?? 0}/2)
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4 space-y-3">
|
</span>
|
||||||
{(submitPhase === "uploading" || submitPhase === "saving") && (
|
</label>
|
||||||
<div className="flex items-center gap-2 text-xs text-text/60">
|
{(submitPhase === "uploading" ||
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
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>
|
<span>Uploading...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{previews.length > 0 && (
|
{data.settings.length === 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||||
{previews.map((preview) => (
|
<p className='text-xs text-text/50'>
|
||||||
<div
|
Add game settings to enable screenshot upload
|
||||||
key={preview.url}
|
</p>
|
||||||
className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-lg overflow-hidden border border-border bg-text/10"
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{/* 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'
|
||||||
>
|
>
|
||||||
<img
|
{screenshotFiles.map((file, idx) => (
|
||||||
src={preview.url}
|
<ScreenshotCard
|
||||||
alt={preview.file.name}
|
key={
|
||||||
className="w-full h-full object-cover"
|
file.name +
|
||||||
|
file.size +
|
||||||
|
file.lastModified
|
||||||
|
}
|
||||||
|
file={file}
|
||||||
|
url={previewUrls[idx] || ""}
|
||||||
|
index={idx}
|
||||||
|
onRemove={() => removeFile(file)}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const newFiles = (screenshotFiles || []).filter((f) => f !== preview.file)
|
|
||||||
onScreenshotFilesChange?.(newFiles)
|
|
||||||
}}
|
|
||||||
className="absolute top-1 right-1 p-0.5 rounded bg-black/60 text-white hover:bg-black/80 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Reorder.Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(!screenshotFiles || screenshotFiles.length < 2) && submitPhase !== "uploading" && submitPhase !== "saving" && (
|
{/* Upload area */}
|
||||||
<label className="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-dashed border-border bg-text/5 hover:bg-text/10 transition-colors cursor-pointer">
|
{canUploadMore ? (
|
||||||
<ImagePlus className="h-3.5 w-3.5 text-primary" />
|
<label
|
||||||
<span className="text-xs text-text/70">
|
className={`relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 transition-all cursor-pointer ${
|
||||||
{(!screenshotFiles || screenshotFiles.length === 0) ? "Add screenshots" : "Add another screenshot"}
|
isDragOver
|
||||||
</span>
|
? "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
|
<input
|
||||||
type="file"
|
type='file'
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept='image/jpeg,image/png,image/webp'
|
||||||
multiple
|
multiple
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const files = Array.from(e.target.files || []).filter((f) =>
|
handleFiles(e.target.files)
|
||||||
/image\/(jpeg|png|webp)/.test(f.type)
|
|
||||||
)
|
|
||||||
const current = screenshotFiles || []
|
|
||||||
const combined = [...current, ...files].slice(0, 2)
|
|
||||||
onScreenshotFilesChange?.(combined)
|
|
||||||
e.target.value = ""
|
e.target.value = ""
|
||||||
}}
|
}}
|
||||||
className="sr-only"
|
className='sr-only'
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{screenshotFiles && screenshotFiles.length >= 2 && (
|
|
||||||
<p className="text-xs text-text/50">Maximum 2 screenshots reached</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Notes */}
|
{/* User Notes */}
|
||||||
<div className="space-y-2">
|
<div className='space-y-2'>
|
||||||
<label className="text-xs font-medium text-text/60">Additional Notes</label>
|
<label className='text-xs font-medium text-text/60'>
|
||||||
|
Additional Notes
|
||||||
|
</label>
|
||||||
<TiptapEditor
|
<TiptapEditor
|
||||||
content={userNotes}
|
content={userNotes}
|
||||||
onChange={(json) => onUserNotesChange(JSON.stringify(json))}
|
onChange={(json) => onUserNotesChange(JSON.stringify(json))}
|
||||||
placeholder="Add any extra details about your experience..."
|
placeholder='Add any extra details about your experience...'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -315,23 +637,23 @@ export function ReviewStep({
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -4 }}
|
initial={{ opacity: 0, y: -4 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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"
|
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" />
|
<AlertCircle className='h-4 w-4 text-red-400 shrink-0' />
|
||||||
<p className="text-xs text-red-400">{error}</p>
|
<p className='text-xs text-red-400'>{error}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={isSubmitting}
|
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"
|
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 ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
{submitPhase === "uploading"
|
{submitPhase === "uploading"
|
||||||
? "Uploading screenshots..."
|
? "Uploading screenshots..."
|
||||||
: submitPhase === "saving"
|
: submitPhase === "saving"
|
||||||
@@ -340,7 +662,7 @@ export function ReviewStep({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="h-4 w-4" />
|
<Send className='h-4 w-4' />
|
||||||
Submit Entry
|
Submit Entry
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user