Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useMemo, useRef } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
export default function ScreenshotUploader({
title = 'Archive screenshots',
description = 'Screenshot requirement placeholder for archive uploads',
visible = false,
files = [],
perFileErrors = [],
errors = [],
invalid = false,
showLooksGood = false,
looksGoodText = 'Looks good',
onFilesChange,
min = 1,
max = 5,
}) {
const inputRef = useRef(null)
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const previewItems = useMemo(() => files.map((file) => ({
file,
url: URL.createObjectURL(file),
})), [files])
useEffect(() => {
return () => {
previewItems.forEach((item) => URL.revokeObjectURL(item.url))
}
}, [previewItems])
if (!visible) return null
const emitFiles = (fileList, merge = false) => {
const incoming = Array.from(fileList || [])
const next = merge ? [...files, ...incoming] : incoming
if (typeof onFilesChange === 'function') {
onFilesChange(next.slice(0, max))
}
}
const removeAt = (index) => {
const next = files.filter((_, idx) => idx !== index)
if (typeof onFilesChange === 'function') {
onFilesChange(next)
}
}
return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_28px_rgba(0,0,0,0.3)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/40' : 'border-amber-300/25 from-amber-500/10 to-slate-900/40'}`}>
{/* Intended props: screenshots, minResolution, maxFileSizeMb, required, onChange, onRemove, error */}
<div className={`rounded-lg border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-amber-300/30 bg-black/20'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-100">{title} <span className="text-red-200">(Required)</span></h3>
<span className="rounded-full border border-amber-200/35 bg-amber-500/15 px-2.5 py-1 text-xs text-amber-100">{Math.min(files.length, max)}/{max} screenshots</span>
</div>
<p className="mt-1 text-sm text-amber-100/85">{description}</p>
<div className="mt-3 rounded-lg border border-amber-200/20 bg-amber-500/10 px-3 py-3 text-xs text-amber-50/90">
<p className="font-semibold">Why we need screenshots</p>
<p className="mt-1">Screenshots provide a visual thumbnail and help AI analysis/moderation before archive contents are published.</p>
<p className="mt-2 text-amber-100/85">Rules: JPG/PNG/WEBP · 1280×720 minimum · 10MB max each · {min} to {max} files.</p>
</div>
<div
className={`mt-3 rounded-lg border-2 border-dashed p-4 text-center transition-colors ${invalid ? 'border-red-300/45 bg-red-500/10' : 'border-white/20 bg-white/5 hover:border-amber-300/45 hover:bg-amber-500/5'}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
emitFiles(event.dataTransfer?.files, true)
}}
>
<p className="text-sm text-white/85">Drop screenshots here or click to browse</p>
<button
type="button"
className="mt-2 rounded-md border border-white/20 bg-white/10 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
onClick={() => inputRef.current?.click()}
>
Browse screenshots
</button>
<input
ref={inputRef}
type="file"
className="hidden"
aria-label="Screenshot file input"
multiple
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
onChange={(event) => emitFiles(event.target.files, true)}
/>
</div>
<div className="mt-3 text-xs text-white/70">
{files.length} selected · minimum {min}, maximum {max}
</div>
{showLooksGood && (
<div className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
<span aria-hidden="true"></span>
<span>{looksGoodText}</span>
</div>
)}
{previewItems.length > 0 && (
<ul className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<AnimatePresence initial={false}>
{previewItems.map((item, index) => (
<motion.li
layout={!prefersReducedMotion}
key={`${item.file.name}-${index}`}
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.96 }}
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
transition={quickTransition}
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs"
>
<div className="overflow-hidden rounded-md border border-white/50 bg-black/25">
<img src={item.url} alt={`Screenshot ${index + 1}`} className="h-24 w-full object-cover" />
</div>
<div className="mt-2 truncate text-white/90">{item.file.name}</div>
<div className="mt-1 text-white/55">{Math.round(item.file.size / 1024)} KB</div>
{perFileErrors[index] && <div className="mt-1 rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1 text-red-200">{perFileErrors[index]}</div>}
<button
type="button"
onClick={() => removeAt(index)}
className="mt-2 rounded-md border border-white/20 bg-white/5 px-2.5 py-1 text-[11px] text-white/80 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
>
Remove
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
{errors.length > 0 && (
<ul className="mt-3 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
{error}
</li>
))}
</ul>
)}
{invalid && (
<p className="mt-3 text-xs text-red-200">Continue is blocked until screenshot requirements are valid.</p>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,152 @@
import React, { useEffect, useState } from 'react'
export default function UploadActions({
step = 1,
canStart = false,
canContinue = false,
canPublish = false,
canGoBack = false,
canReset = true,
canCancel = false,
canRetry = false,
isUploading = false,
isProcessing = false,
isPublishing = false,
isCancelling = false,
disableReason = 'Complete required fields',
onStart,
onContinue,
onPublish,
onBack,
onCancel,
onReset,
onRetry,
onSaveDraft,
showSaveDraft = false,
mobileSticky = true,
resetLabel = 'Reset',
}) {
const [confirmCancel, setConfirmCancel] = useState(false)
useEffect(() => {
if (!confirmCancel) return
const timer = window.setTimeout(() => setConfirmCancel(false), 3200)
return () => window.clearTimeout(timer)
}, [confirmCancel])
const handleCancel = () => {
if (!canCancel || isCancelling) return
if (!confirmCancel) {
setConfirmCancel(true)
return
}
setConfirmCancel(false)
onCancel?.()
}
const renderPrimary = () => {
if (step === 1) {
const disabled = !canStart || isUploading || isProcessing || isCancelling
const label = isUploading ? 'Uploading…' : isProcessing ? 'Processing…' : 'Start upload'
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Start upload'}
onClick={() => onStart?.()}
className={`rounded-lg px-5 py-2.5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/75 ${disabled ? 'cursor-not-allowed bg-emerald-700/55 text-white/75' : 'bg-emerald-500 text-white hover:bg-emerald-400 shadow-[0_10px_28px_rgba(16,185,129,0.32)] ring-1 ring-emerald-200/40'}`}
>
{label}
</button>
)
}
if (step === 2) {
const disabled = !canContinue
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Continue to Publish'}
onClick={() => onContinue?.()}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 ${disabled ? 'cursor-not-allowed bg-sky-700/45 text-sky-100/75' : 'bg-sky-400 text-slate-950 hover:bg-sky-300 shadow-[0_10px_28px_rgba(56,189,248,0.28)] ring-1 ring-sky-100/45'}`}
>
Continue to Publish
</button>
)
}
const disabled = !canPublish || isPublishing
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Publish artwork'}
onClick={() => onPublish?.()}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/75 ${disabled ? 'cursor-not-allowed bg-emerald-700/45 text-emerald-100/75' : 'bg-emerald-400 text-slate-950 shadow-[0_0_0_1px_rgba(167,243,208,0.85),0_0_24px_rgba(52,211,153,0.45)] hover:bg-emerald-300'}`}
>
{isPublishing ? 'Publishing…' : 'Publish'}
</button>
)
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20' : ''} rounded-xl border border-white/10 bg-slate-950/80 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur sm:p-4 lg:static lg:shadow-none`}>
<div className="flex flex-wrap items-center justify-end gap-2.5">
{canGoBack && (
<button
type="button"
onClick={() => onBack?.()}
className="rounded-lg border border-white/30 bg-white/10 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
Back
</button>
)}
{showSaveDraft && (
<button
type="button"
onClick={() => onSaveDraft?.()}
className="rounded-lg border border-purple-300/45 bg-purple-500/20 px-3.5 py-2 text-sm font-medium text-purple-50 transition hover:bg-purple-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-300/70"
>
Save draft
</button>
)}
{step === 1 && canCancel && (
<button
type="button"
onClick={handleCancel}
disabled={isCancelling}
title={confirmCancel ? 'Click again to confirm cancel' : 'Cancel current upload'}
className="rounded-lg border border-amber-300/45 bg-amber-500/20 px-3.5 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/75 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCancelling ? 'Cancelling…' : confirmCancel ? 'Cancel upload?' : 'Cancel'}
</button>
)}
{canRetry && (
<button
type="button"
onClick={() => onRetry?.()}
className="rounded-lg border border-amber-300/45 bg-amber-500/20 px-3.5 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/75"
>
Retry
</button>
)}
{canReset && (
<button
type="button"
onClick={() => onReset?.()}
className="rounded-lg border border-white/30 bg-white/10 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
{resetLabel}
</button>
)}
{renderPrimary()}
</div>
</footer>
)
}

View File

@@ -0,0 +1,199 @@
import React, { useRef, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
function getExtension(fileName = '') {
const parts = String(fileName).toLowerCase().split('.')
return parts.length > 1 ? parts.pop() : ''
}
function detectPrimaryType(file) {
if (!file) return 'unknown'
const extension = getExtension(file.name)
const mime = String(file.type || '').toLowerCase()
const imageExt = new Set(['jpg', 'jpeg', 'png', 'webp'])
const archiveExt = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
const imageMime = new Set(['image/jpeg', 'image/png', 'image/webp'])
const archiveMime = new Set([
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip',
'application/x-gzip',
'application/octet-stream',
])
if (imageMime.has(mime) || imageExt.has(extension)) return 'image'
if (archiveMime.has(mime) || archiveExt.has(extension)) return 'archive'
return 'unsupported'
}
export default function UploadDropzone({
title = 'Upload file',
description = 'Drop file here or click to browse',
fileName = '',
fileHint = 'No file selected yet',
previewUrl = '',
fileMeta = null,
errors = [],
invalid = false,
showLooksGood = false,
looksGoodText = 'Looks good',
locked = false,
onPrimaryFileChange,
onValidationResult,
}) {
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
const prefersReducedMotion = useReducedMotion()
const dragTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const emitFile = (file) => {
const detectedType = detectPrimaryType(file)
if (typeof onPrimaryFileChange === 'function') {
onPrimaryFileChange(file, { detectedType })
}
if (typeof onValidationResult === 'function') {
onValidationResult({ file, detectedType })
}
}
return (
<section className={`rounded-xl border bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/50'}`}>
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
<motion.div
data-testid="upload-dropzone"
role="button"
aria-disabled={locked ? 'true' : 'false'}
tabIndex={locked ? -1 : 0}
onClick={() => {
if (locked) return
inputRef.current?.click()
}}
onKeyDown={(event) => {
if (locked) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
if (locked) return
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (locked) return
event.preventDefault()
setDragging(false)
const droppedFile = event.dataTransfer?.files?.[0]
if (droppedFile) emitFile(droppedFile)
}}
animate={prefersReducedMotion ? undefined : {
scale: dragging ? 1.01 : 1,
borderColor: invalid ? 'rgba(252,165,165,0.7)' : dragging ? 'rgba(103,232,249,0.9)' : 'rgba(56,189,248,0.35)',
backgroundColor: invalid ? 'rgba(23,68,68,0.10)' : dragging ? 'rgba(6,182,212,0.20)' : 'rgba(14,165,233,0.05)',
}}
transition={dragTransition}
className={`group rounded-xl border-2 border-dashed border-white/50 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
>
{previewUrl ? (
<div className="mt-2 grid place-items-center">
<div className="relative w-full max-w-[520px]">
<img src={previewUrl} alt="Selected preview" className="mx-auto max-h-64 w-auto rounded-lg object-contain" />
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-black/40 px-2 py-1 text-xs text-white/90">Click to replace</div>
</div>
</div>
) : (
<>
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-sky-400/60 bg-sky-500/12 text-sky-100 shadow-sm">
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
<path d="M7 10l5-5 5 5" />
<path d="M12 5v10" />
</svg>
</div>
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
<p className="mt-1 text-xs text-soft">{description}</p>
<p className="mt-1 text-xs text-soft">Accepted: JPG, JPEG, PNG, WEBP, ZIP, RAR, 7Z, TAR, GZ</p>
<p className="text-xs text-soft">Max size: images 50MB · archives 200MB</p>
<span className={`btn-secondary mt-3 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
Click to browse files
</span>
</>
)}
<input
ref={inputRef}
type="file"
className="hidden"
aria-label="Upload file input"
disabled={locked}
accept=".jpg,.jpeg,.png,.webp,.zip,.rar,.7z,.tar,.gz,image/jpeg,image/png,image/webp"
onChange={(event) => {
const selectedFile = event.target.files?.[0]
if (selectedFile) {
emitFile(selectedFile)
}
}}
/>
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
<div className="mt-3 rounded-lg border border-white/50 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
<div className="font-medium text-white/85">Selected file</div>
<div className="mt-1 truncate">{fileName || fileHint}</div>
{fileMeta && (
<div className="mt-1 flex flex-wrap gap-2 text-xs text-white/60">
<span>Type: <span className="text-white/80">{fileMeta.type || '—'}</span></span>
<span>·</span>
<span>Size: <span className="text-white/80">{fileMeta.size || '—'}</span></span>
<span>·</span>
<span>Resolution: <span className="text-white/80">{fileMeta.resolution || '—'}</span></span>
</div>
)}
</div>
)}
{showLooksGood && (
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
<span aria-hidden="true"></span>
<span>{looksGoodText}</span>
</div>
)}
<AnimatePresence initial={false}>
{errors.length > 0 && (
<motion.div
key="dropzone-errors"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={dragTransition}
className="mt-4 rounded-lg border border-red-300/40 bg-red-500/10 p-3 text-left"
>
<p className="text-xs font-semibold uppercase tracking-wide text-red-100">Please fix the following</p>
<ul className="mt-2 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
{error}
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</section>
)
}

View File

@@ -0,0 +1,76 @@
import React from 'react'
export default function UploadPreview({
title = 'Preview',
description = 'Live artwork preview placeholder',
previewUrl = '',
isArchive = false,
metadata = {
resolution: '—',
size: '—',
type: '—',
},
warnings = [],
errors = [],
invalid = false,
}) {
return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/45'}`}>
{/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */}
<div className={`rounded-xl border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-white/50 bg-black/25'}`}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">
{isArchive ? 'Archive' : 'Image'}
</span>
</div>
<p className="mt-1 text-sm text-white/65">{description}</p>
<div className="mt-4 flex flex-col md:flex-row gap-4 items-start">
<div className="w-40 h-40 rounded-lg overflow-hidden bg-black/40 ring-1 ring-white/10 flex items-center justify-center">
{previewUrl && !isArchive ? (
<img src={previewUrl} alt="Upload preview" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-sm text-soft">{isArchive ? 'Archive selected' : 'Image preview placeholder'}</span>
)}
</div>
<div className="flex-1 space-y-2 text-sm">
<div>
<span className="text-soft">Type</span>
<span className="text-white ml-2">{metadata.type}</span>
</div>
<div>
<span className="text-soft">Size</span>
<span className="text-white ml-2">{metadata.size}</span>
</div>
<div>
<span className="text-soft">Resolution</span>
<span className="text-white ml-2">{metadata.resolution}</span>
</div>
{errors.length > 0 && (
<ul className="space-y-1" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="text-red-400 text-xs">
{error}
</li>
))}
</ul>
)}
</div>
</div>
{warnings.length > 0 && (
<ul className="mt-4 space-y-1 text-xs text-amber-100" role="status" aria-live="polite">
{warnings.map((warning, index) => (
<li key={`${warning}-${index}`} className="rounded-md border border-amber-300/35 bg-amber-500/10 px-2 py-1">
{warning}
</li>
))}
</ul>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,152 @@
import React from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
export default function UploadProgress({
title = 'Upload Artwork',
description = 'Preload → Details → Publish',
progress = 24,
status = 'Idle',
state,
processingStatus,
processingLabel = '',
isCancelling = false,
error = '',
onRetry,
onReset,
}) {
const prefersReducedMotion = useReducedMotion()
const getRecoveryHint = () => {
const text = String(error || '').toLowerCase()
if (!text) return ''
if (text.includes('network') || text.includes('timeout') || text.includes('failed to fetch')) {
return 'Your connection may be unstable. Retry now or wait a moment and try again.'
}
if (text.includes('busy') || text.includes('unavailable') || text.includes('503') || text.includes('server')) {
return 'The server looks busy right now. Waiting 2030 seconds before retrying can help.'
}
if (text.includes('validation') || text.includes('invalid') || text.includes('too large') || text.includes('format')) {
return 'Please review the file requirements, then update your selection and try again.'
}
return 'You can retry now, or reset this upload and start again with the same files.'
}
const recoveryHint = getRecoveryHint()
const resolvedStatus = (() => {
if (isCancelling) return 'Processing'
if (state === 'error') return 'Error'
if (processingStatus === 'ready') return 'Ready'
if (state === 'uploading') return 'Uploading'
if (state === 'processing' || state === 'finishing' || state === 'publishing') return 'Processing'
if (status) return status
return 'Idle'
})()
const statusTheme = {
Idle: 'border-slate-400/35 bg-slate-400/15 text-slate-200',
Uploading: 'border-sky-400/35 bg-sky-400/15 text-sky-100',
Processing: 'border-amber-400/35 bg-amber-400/15 text-amber-100',
Ready: 'border-emerald-400/35 bg-emerald-400/15 text-emerald-100',
Error: 'border-red-400/35 bg-red-400/15 text-red-100',
}
const statusColors = {
Idle: { borderColor: 'rgba(148,163,184,0.35)', backgroundColor: 'rgba(148,163,184,0.15)', color: 'rgb(226,232,240)' },
Uploading: { borderColor: 'rgba(56,189,248,0.35)', backgroundColor: 'rgba(56,189,248,0.15)', color: 'rgb(224,242,254)' },
Processing: { borderColor: 'rgba(251,191,36,0.35)', backgroundColor: 'rgba(251,191,36,0.15)', color: 'rgb(254,243,199)' },
Ready: { borderColor: 'rgba(52,211,153,0.35)', backgroundColor: 'rgba(52,211,153,0.15)', color: 'rgb(209,250,229)' },
Error: { borderColor: 'rgba(248,113,113,0.35)', backgroundColor: 'rgba(248,113,113,0.15)', color: 'rgb(254,226,226)' },
}
const quickTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const stepLabels = ['Preload', 'Details', 'Publish']
const stepIndex = progress >= 100 ? 2 : progress >= 34 ? 1 : 0
return (
<header className="rounded-xl border border-white/50 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6">
{/* Intended props: step, steps, phase, badge, progress, statusMessage */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">{title}</h1>
<p className="mt-1 text-sm text-white/65">{description}</p>
</div>
<motion.span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusTheme[resolvedStatus] || statusTheme.Idle}`}
animate={statusColors[resolvedStatus] || statusColors.Idle}
transition={quickTransition}
>
{resolvedStatus}
</motion.span>
</div>
<div className="mt-4 flex items-center gap-2 overflow-x-auto">
{stepLabels.map((label, idx) => {
const active = idx <= stepIndex
return (
<div key={label} className="flex items-center gap-2">
<span className={`rounded-full border px-3 py-1 text-xs ${active ? 'border-emerald-400/40 bg-emerald-400/20 text-emerald-100' : 'border-white/15 bg-white/5 text-white/55'}`}>
{label}
</span>
{idx < stepLabels.length - 1 && <span className="text-white/30"></span>}
</div>
)
})}
</div>
<div className="mt-4">
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full"
style={{
width: `${Math.max(0, Math.min(100, progress))}%`,
background: 'linear-gradient(90deg,#38bdf8,#06b6d4,#34d399)',
transition: prefersReducedMotion ? 'none' : 'width 200ms ease-out',
}}
/>
</div>
<p className="mt-2 text-right text-xs text-white/55">{Math.round(progress)}%</p>
</div>
<AnimatePresence initial={false}>
{(state === 'processing' || state === 'finishing' || state === 'publishing' || isCancelling) && (
<motion.div
key="processing-note"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={quickTransition}
className="mt-3 rounded-lg border border-cyan-300/25 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100"
>
{processingLabel || 'Analyzing content'} you can continue editing details while processing finishes.
</motion.div>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{error && (
<motion.div
key="progress-error"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={quickTransition}
className="mt-3 rounded-lg border border-rose-200/25 bg-rose-400/8 px-3 py-2"
>
<p className="text-sm font-medium text-rose-100">Something went wrong while uploading.</p>
<p className="mt-1 text-xs text-rose-100/90">You can retry safely. {error}</p>
{recoveryHint && <p className="mt-1 text-xs text-rose-100/80">{recoveryHint}</p>}
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={onRetry} className="rounded-md border border-rose-200/35 bg-rose-400/10 px-2.5 py-1 text-xs text-rose-100 transition hover:bg-rose-400/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200/75">Retry</button>
<button type="button" onClick={onReset} className="rounded-md border border-white/25 bg-white/10 px-2.5 py-1 text-xs text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60">Reset</button>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
import TagInput from '../tags/TagInput'
export default function UploadSidebar({
title = 'Artwork details',
description = 'Complete metadata before publishing',
showHeader = true,
metadata,
suggestedTags = [],
errors = {},
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleRights,
}) {
return (
<aside className="rounded-2xl border border-white/7 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_10px_24px_rgba(0,0,0,0.22)] sm:p-7">
{showHeader && (
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-white/65">{description}</p>
</div>
)}
<div className="space-y-5">
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Basics</h4>
<p className="mt-1 text-xs text-white/60">Add a clear title and short description.</p>
</div>
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-white/90">Title <span className="text-red-300">*</span></span>
<input
id="upload-sidebar-title"
value={metadata.title}
onChange={(event) => onChangeTitle?.(event.target.value)}
className={`mt-2 w-full rounded-xl border bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 ${errors.title ? 'border-red-300/60 focus:ring-red-300/70' : 'border-white/15 focus:ring-sky-300/70'}`}
placeholder="Give your artwork a clear title"
/>
{errors.title && <p className="mt-1 text-xs text-red-200">{errors.title}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-white/90">Description</span>
<textarea
id="upload-sidebar-description"
value={metadata.description}
onChange={(event) => onChangeDescription?.(event.target.value)}
rows={5}
className="mt-2 w-full rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-300/70"
placeholder="Describe your artwork (Markdown supported)."
/>
</label>
</div>
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Tags</h4>
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
</div>
<TagInput
value={metadata.tags}
onChange={(nextTags) => onChangeTags?.(nextTags)}
suggestedTags={suggestedTags}
maxTags={15}
minLength={2}
maxLength={32}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
placeholder="Type tags (e.g. cyberpunk, city)"
/>
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<label className="flex items-start gap-3 text-sm text-white/90">
<input
id="upload-sidebar-rights"
type="checkbox"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
className="mt-0.5 h-5 w-5 rounded-md border border-white/30 bg-slate-900/70 text-emerald-400 accent-emerald-500 focus:ring-2 focus:ring-emerald-400/40"
/>
<span>
I confirm I own the rights to this content. <span className="text-red-300">*</span>
<span className="mt-1 block text-xs text-white/60">Required before publishing.</span>
{errors.rights && <span className="mt-1 block text-xs text-red-200">{errors.rights}</span>}
</span>
</label>
</section>
</div>
</aside>
)
}

View File

@@ -0,0 +1,53 @@
import React from 'react'
export default function UploadStepper({ steps = [], activeStep = 1, highestUnlockedStep = 1, onStepClick }) {
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
return (
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4">
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto sm:gap-3">
{steps.map((step, index) => {
const number = index + 1
const isActive = number === safeActive
const isComplete = number < safeActive
const isLocked = number > highestUnlockedStep
const canNavigate = number < safeActive && !isLocked
const baseBtn = 'inline-flex items-center gap-2 rounded-full border px-2.5 py-1.5 text-xs sm:px-3'
const stateClass = isActive
? 'border-sky-300/80 bg-sky-500/30 text-white shadow-[0_8px_24px_rgba(14,165,233,0.12)]'
: isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
: isLocked
? 'cursor-default border-white/50 bg-white/5 text-white/40'
: 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10'
const circleClass = isComplete
? 'border-emerald-300/60 bg-emerald-500/20 text-emerald-100'
: isActive
? 'border-sky-300/60 bg-sky-500/30 text-white'
: 'border-white/20 bg-white/5 text-white/80'
return (
<li key={step.key} className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => canNavigate && onStepClick?.(number)}
disabled={isLocked}
aria-disabled={isLocked ? 'true' : 'false'}
aria-current={isActive ? 'step' : undefined}
className={`${baseBtn} ${stateClass}`}
>
<span className={`grid h-5 w-5 place-items-center rounded-full border text-[11px] ${circleClass}`}>
{isComplete ? '✓' : number}
</span>
<span className="whitespace-nowrap">{step.label}</span>
</button>
{index < steps.length - 1 && <span className="text-white/50"></span>}
</li>
)
})}
</ol>
</nav>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act } from 'react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UploadWizard from '../UploadWizard'
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false } = {}) {
window.axios = {
post: vi.fn((url, payload, config = {}) => {
if (url === '/api/uploads/init') {
if (initError) return Promise.reject(initError)
return Promise.resolve({
data: {
session_id: 'session-1',
upload_token: 'token-1',
},
})
}
if (url === '/api/uploads/chunk') {
if (holdChunk) {
return new Promise((resolve, reject) => {
if (config?.signal?.aborted) {
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
return
}
config?.signal?.addEventListener?.('abort', () => reject({ name: 'CanceledError', code: 'ERR_CANCELED' }))
setTimeout(() => resolve({ data: { received_bytes: 1024, progress: 55 } }), 20)
})
}
const offset = Number(payload?.get?.('offset') || 0)
const chunkSize = Number(payload?.get?.('chunk_size') || 0)
const totalSize = Number(payload?.get?.('total_size') || 1)
const received = Math.min(totalSize, offset + chunkSize)
return Promise.resolve({
data: {
received_bytes: received,
progress: Math.round((received / totalSize) * 100),
},
})
}
if (url === '/api/uploads/finish') {
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
}
if (url === '/api/uploads/session-1/publish') {
return Promise.resolve({ data: { success: true, status: 'published' } })
}
if (url === '/api/uploads/cancel') {
return Promise.resolve({ data: { success: true, status: 'cancelled' } })
}
return Promise.reject(new Error(`Unhandled POST ${url}`))
}),
get: vi.fn((url) => {
if (url === '/api/uploads/status/session-1') {
return Promise.resolve({
data: {
id: 'session-1',
processing_state: statusValue,
status: statusValue,
},
})
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
}),
}
}
async function flushUi() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
}
async function renderWizard(props = {}) {
await act(async () => {
render(<UploadWizard {...props} />)
})
await flushUi()
}
async function uploadPrimary(file) {
await act(async () => {
const input = screen.getByLabelText('Upload file input')
await userEvent.upload(input, file)
})
await flushUi()
}
async function uploadScreenshot(file) {
await act(async () => {
const input = await screen.findByLabelText('Screenshot file input')
await userEvent.upload(input, file)
})
await flushUi()
}
async function completeStep1ToReady() {
await uploadPrimary(new File(['img'], 'ready.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /continue to publish/i })).not.toBeNull()
})
}
describe('UploadWizard step flow', () => {
let originalImage
let originalScrollIntoView
let consoleErrorSpy
beforeEach(() => {
window.URL.createObjectURL = vi.fn(() => `blob:${Math.random().toString(16).slice(2)}`)
window.URL.revokeObjectURL = vi.fn()
originalImage = global.Image
originalScrollIntoView = Element.prototype.scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const text = args.map((arg) => String(arg)).join(' ')
if (text.includes('not configured to support act')) return
if (text.includes('not wrapped in act')) return
console.warn(...args)
})
global.Image = class MockImage {
set src(_value) {
this.naturalWidth = 1920
this.naturalHeight = 1080
setTimeout(() => {
if (typeof this.onload === 'function') this.onload()
}, 0)
}
}
})
afterEach(() => {
global.Image = originalImage
Element.prototype.scrollIntoView = originalScrollIntoView
consoleErrorSpy?.mockRestore()
cleanup()
vi.restoreAllMocks()
})
it('renders 3-step stepper', () => {
installAxiosStubs()
return renderWizard({ initialDraftId: 301 }).then(() => {
expect(screen.getByRole('navigation', { name: /upload steps/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /1 upload/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /2 details/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /3 publish/i })).not.toBeNull()
})
})
it('marks locked steps with aria-disabled and blocks click', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 307 })
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
const detailsStep = within(stepper).getByRole('button', { name: /2 details/i })
const publishStep = within(stepper).getByRole('button', { name: /3 publish/i })
expect(detailsStep.getAttribute('aria-disabled')).toBe('true')
expect(publishStep.getAttribute('aria-disabled')).toBe('true')
await act(async () => {
await userEvent.click(detailsStep)
})
expect(screen.getByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
expect(screen.queryByText(/add details/i)).toBeNull()
})
it('keeps step 2 hidden until step 1 upload is ready', async () => {
installAxiosStubs({ statusValue: 'processing' })
await renderWizard({ initialDraftId: 302 })
expect(screen.queryByText(/artwork details/i)).toBeNull()
await uploadPrimary(new File(['img'], 'x.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: /continue to publish/i })).toBeNull()
})
expect(screen.queryByText(/artwork details/i)).toBeNull()
})
it('requires archive screenshot before start upload enables', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 303 })
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
const start = await screen.findByRole('button', { name: /start upload/i })
await waitFor(() => {
expect(start.disabled).toBe(true)
})
await uploadScreenshot(new File(['shot'], 'screen.png', { type: 'image/png' }))
await waitFor(() => {
expect(start.disabled).toBe(false)
})
})
it('allows navigation back to completed previous step', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 304 })
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
})
it('triggers scroll-to-top behavior on step change', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 308 })
const scrollSpy = Element.prototype.scrollIntoView
const initialCalls = scrollSpy.mock.calls.length
await completeStep1ToReady()
await waitFor(() => {
expect(scrollSpy.mock.calls.length).toBeGreaterThan(initialCalls)
})
})
it('shows publish only on step 3 and only after ready_to_publish path', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 305, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
await act(async () => {
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(screen.getByText(/your artwork is live/i)).not.toBeNull()
})
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
})
it('keeps mobile sticky action bar visible class', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })
const bar = screen.getByTestId('wizard-action-bar')
expect((bar.className || '').includes('sticky')).toBe(true)
expect((bar.className || '').includes('bottom-0')).toBe(true)
})
it('locks step 1 file input after upload and unlocks after reset', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 309 })
await completeStep1ToReady()
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
await waitFor(() => {
const dropzoneButton = screen.getByTestId('upload-dropzone')
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
})
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
})
await waitFor(() => {
const unlockedDropzone = screen.getByTestId('upload-dropzone')
expect(unlockedDropzone.getAttribute('aria-disabled')).toBe('false')
})
})
})