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:
2026-05-10 16:38:27 +08:00
parent 9b1e428bc7
commit f11c8343d4
4 changed files with 2110 additions and 1006 deletions
+1 -1
View File
@@ -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
+466 -144
View File
@@ -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
</> </>
)} )}