Upload beautify
This commit is contained in:
152
resources/js/components/upload/UploadProgress.jsx
Normal file
152
resources/js/components/upload/UploadProgress.jsx
Normal 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 20–30 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user