feat: atomic FormData submit, back button on review, submit phase state (Task 6)

This commit is contained in:
2026-05-10 15:36:05 +08:00
parent 6f64f1d89a
commit 9dc7013fbf
2 changed files with 28 additions and 55 deletions
+23 -46
View File
@@ -44,10 +44,8 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
const [currentStep, setCurrentStep] = useState(0) const [currentStep, setCurrentStep] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [screenshotFiles, setScreenshotFiles] = useState<File[]>([]) const [screenshotFiles, setScreenshotFiles] = useState<File[]>([])
const [screenshotUploading, setScreenshotUploading] = useState(false) const [submitPhase, setSubmitPhase] = useState<"idle" | "uploading" | "saving" | "success" | "error">("idle")
const [screenshotError, setScreenshotError] = useState<string | null>(null)
// Step 0: Setup — Hardware // Step 0: Setup — Hardware
const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "") const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "")
@@ -260,10 +258,10 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true) setIsSubmitting(true)
setSubmitPhase("uploading")
setError(null) setError(null)
try { try {
// Resolve version ID (may create a new version)
const versionId = await resolveVersionId() const versionId = await resolveVersionId()
const payload = { const payload = {
@@ -291,67 +289,46 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
antiCheatStatus: antiCheat.antiCheatStatus, antiCheatStatus: antiCheat.antiCheatStatus,
} }
const formData = new FormData()
formData.append("payload", JSON.stringify(payload))
for (const file of screenshotFiles) {
formData.append("screenshots", file)
}
setSubmitPhase("saving")
const url = editEntry const url = editEntry
? `/api/performance/${editEntry.id}/edit` ? `/api/performance/${editEntry.id}/edit`
: "/api/performance/submit" : "/api/performance/submit"
const method = editEntry ? "PATCH" : "POST" const method = editEntry ? "PATCH" : "POST"
const res = await fetch(url, { const res = await fetch(url, { method, body: formData })
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
throw new Error(data.error || `Failed to ${editEntry ? "update" : "submit"} entry`) throw new Error(data.error || `Failed to ${editEntry ? "update" : "submit"} entry`)
} }
const data = await res.json() setSubmitPhase("success")
// Inline screenshot upload for new entries
if (!editEntry && data.id && screenshotFiles.length > 0) {
setScreenshotUploading(true)
try {
const formData = new FormData()
screenshotFiles.forEach((file) => formData.append("screenshots", file))
const uploadRes = await fetch(`/api/performance/${data.id}/screenshots`, {
method: "POST",
body: formData,
})
if (!uploadRes.ok) {
console.warn("Screenshot upload failed:", await uploadRes.text())
}
} catch (err) {
console.warn("Screenshot upload error:", err)
} finally {
setScreenshotUploading(false)
}
}
setSuccess(true)
setTimeout(() => { setTimeout(() => {
router.push(`/game/${gameId}`) router.push(`/game/${gameId}`)
}, 2000) }, 2000)
} catch (err) { } catch (err) {
setSubmitPhase("error")
setError(err instanceof Error ? err.message : "An error occurred") setError(err instanceof Error ? err.message : "An error occurred")
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
if (success) { if (submitPhase === "success") {
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center justify-center py-16 text-center" className="flex flex-col items-center justify-center py-16 text-center"
> >
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold mb-2">Entry Submitted!</h2> <h2 className="text-xl font-bold mb-2">Entry Submitted!</h2>
<p className="text-sm text-text/60">Redirecting to game page...</p> <p className="text-sm text-text/60">Redirecting to game page...</p>
</motion.div> </motion.div>
@@ -429,34 +406,34 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
error={error} error={error}
screenshotFiles={screenshotFiles} screenshotFiles={screenshotFiles}
onScreenshotFilesChange={setScreenshotFiles} onScreenshotFilesChange={setScreenshotFiles}
screenshotUploading={screenshotUploading} submitPhase={submitPhase}
screenshotError={screenshotError}
/> />
)} )}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* Navigation Buttons */} {/* Navigation Buttons */}
{currentStep < 4 && ( <div className="flex justify-between">
<div className="flex justify-between"> {currentStep > 0 && (
<button <button
type="button" type="button"
onClick={handleBack} onClick={handleBack}
disabled={currentStep === 0}
className="px-6 py-2 rounded-lg border border-border text-sm font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed" className="px-6 py-2 rounded-lg border border-border text-sm font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
> >
Back Back
</button> </button>
)}
{currentStep < 4 && (
<button <button
type="button" type="button"
onClick={handleNext} onClick={handleNext}
disabled={!canProceed()} disabled={!canProceed()}
className="px-6 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ml-auto"
> >
Next Next
</button> </button>
</div> )}
)} </div>
</div> </div>
) )
} }
+5 -9
View File
@@ -34,8 +34,7 @@ interface ReviewStepProps {
error: string | null error: string | null
screenshotFiles: File[] screenshotFiles: File[]
onScreenshotFilesChange: (files: File[]) => void onScreenshotFilesChange: (files: File[]) => void
screenshotUploading: boolean submitPhase: "idle" | "uploading" | "saving" | "success" | "error"
screenshotError: string | null
} }
function SectionHeader({ icon: Icon, label }: { icon: React.ElementType; label: string }) { function SectionHeader({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
@@ -70,8 +69,7 @@ export function ReviewStep({
error, error,
screenshotFiles, screenshotFiles,
onScreenshotFilesChange, onScreenshotFilesChange,
screenshotUploading, submitPhase,
screenshotError,
}: ReviewStepProps) { }: ReviewStepProps) {
const { hardwareName, gameVersionLabel, antiCheat, performance, environment, settings } = data const { hardwareName, gameVersionLabel, antiCheat, performance, environment, settings } = data
@@ -218,7 +216,7 @@ export function ReviewStep({
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-border bg-text/5 p-4 space-y-3"> <div className="rounded-lg border border-border bg-text/5 p-4 space-y-3">
{screenshotUploading && ( {(submitPhase === "uploading" || submitPhase === "saving") && (
<div className="flex items-center gap-2 text-xs text-text/60"> <div className="flex items-center gap-2 text-xs text-text/60">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>Uploading...</span> <span>Uploading...</span>
@@ -252,7 +250,7 @@ export function ReviewStep({
</div> </div>
)} )}
{(!screenshotFiles || screenshotFiles.length < 2) && !screenshotUploading && ( {(!screenshotFiles || screenshotFiles.length < 2) && submitPhase !== "uploading" && submitPhase !== "saving" && (
<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"> <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">
<ImagePlus className="h-3.5 w-3.5 text-primary" /> <ImagePlus className="h-3.5 w-3.5 text-primary" />
<span className="text-xs text-text/70"> <span className="text-xs text-text/70">
@@ -280,9 +278,7 @@ export function ReviewStep({
<p className="text-xs text-text/50">Maximum 2 screenshots reached</p> <p className="text-xs text-text/50">Maximum 2 screenshots reached</p>
)} )}
{screenshotError && (
<p className="text-xs text-red-400">{screenshotError}</p>
)}
</div> </div>
)} )}
</div> </div>