Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
import React from 'react'
|
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
|
|
|
/**
|
|
* UploadOverlay
|
|
*
|
|
* A centered modal-style progress overlay shown while an upload or processing
|
|
* job is in flight.
|
|
*
|
|
* Shows:
|
|
* - State icon + label + live percentage
|
|
* - Thick animated progress bar with gradient
|
|
* - Processing transparency label (what the backend is doing)
|
|
* - Error strip with Retry / Reset when something goes wrong
|
|
*/
|
|
|
|
const ACTIVE_STATES = new Set([
|
|
'initializing',
|
|
'uploading',
|
|
'finishing',
|
|
'processing',
|
|
])
|
|
|
|
const STATE_META = {
|
|
initializing: {
|
|
label: 'Initializing',
|
|
sublabel: 'Preparing your upload…',
|
|
color: 'text-sky-300',
|
|
barColor: 'from-sky-500 via-sky-400 to-cyan-300',
|
|
icon: (
|
|
<svg className="h-4 w-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
|
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
),
|
|
},
|
|
uploading: {
|
|
label: 'Uploading',
|
|
sublabel: 'Sending your file to the server…',
|
|
color: 'text-sky-300',
|
|
barColor: 'from-sky-500 via-cyan-400 to-teal-300',
|
|
icon: (
|
|
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L9 11.586V4a1 1 0 011-1zM3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
),
|
|
},
|
|
finishing: {
|
|
label: 'Finishing',
|
|
sublabel: 'Wrapping up the transfer…',
|
|
color: 'text-cyan-300',
|
|
barColor: 'from-cyan-500 via-teal-400 to-emerald-300',
|
|
icon: (
|
|
<svg className="h-4 w-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
|
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
),
|
|
},
|
|
processing: {
|
|
label: 'Processing',
|
|
sublabel: 'Analyzing your artwork…',
|
|
color: 'text-amber-300',
|
|
barColor: 'from-amber-500 via-yellow-400 to-lime-300',
|
|
icon: (
|
|
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
|
</svg>
|
|
),
|
|
},
|
|
error: {
|
|
label: 'Upload failed',
|
|
sublabel: null,
|
|
color: 'text-rose-300',
|
|
barColor: 'from-rose-600 via-rose-500 to-rose-400',
|
|
icon: (
|
|
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
),
|
|
},
|
|
}
|
|
|
|
export default function UploadOverlay({
|
|
machineState = 'idle',
|
|
progress = 0,
|
|
processingLabel = null,
|
|
error = null,
|
|
onRetry,
|
|
onReset,
|
|
}) {
|
|
const prefersReducedMotion = useReducedMotion()
|
|
|
|
const isVisible = ACTIVE_STATES.has(machineState) || machineState === 'error'
|
|
const meta = STATE_META[machineState] ?? STATE_META.uploading
|
|
|
|
const barTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.4, ease: 'easeOut' }
|
|
|
|
const overlayTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.25, ease: [0.32, 0.72, 0, 1] }
|
|
|
|
const displayLabel = processingLabel || meta.sublabel
|
|
|
|
return (
|
|
<AnimatePresence initial={false}>
|
|
{isVisible && (
|
|
<motion.div
|
|
key="upload-overlay"
|
|
initial={prefersReducedMotion ? false : { opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={prefersReducedMotion ? {} : { opacity: 0 }}
|
|
transition={overlayTransition}
|
|
className="fixed inset-0 z-[80] flex items-center justify-center p-4 sm:p-6"
|
|
>
|
|
<div className="absolute inset-0 bg-slate-950/72 backdrop-blur-sm" aria-hidden="true" />
|
|
|
|
<motion.div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="upload-overlay-title"
|
|
aria-describedby="upload-overlay-description"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 18, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={prefersReducedMotion ? {} : { opacity: 0, y: 12, scale: 0.98 }}
|
|
transition={overlayTransition}
|
|
className="relative w-full max-w-xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(6,14,24,0.96),rgba(2,8,23,0.92))] px-5 pb-5 pt-5 shadow-[0_30px_120px_rgba(2,8,23,0.72)] ring-1 ring-inset ring-white/8 backdrop-blur-xl sm:px-6 sm:pb-6 sm:pt-6"
|
|
>
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-label={`${meta.label}${progress > 0 ? ` — ${progress}%` : ''}`}
|
|
>
|
|
<div className="mb-4 flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className={`flex items-center gap-2 ${meta.color}`}>
|
|
{meta.icon}
|
|
<span id="upload-overlay-title" className="text-xl font-semibold tracking-tight">
|
|
{meta.label}
|
|
</span>
|
|
{machineState !== 'error' && (
|
|
<span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-current opacity-80" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p id="upload-overlay-description" className="mt-2 text-sm text-white/60">
|
|
{machineState === 'error'
|
|
? 'The upload was interrupted. You can retry safely or start over.'
|
|
: 'Keep this tab open while we finish the upload and process your artwork.'}
|
|
</p>
|
|
</div>
|
|
|
|
{machineState !== 'error' && (
|
|
<span className={`shrink-0 tabular-nums text-2xl font-bold ${meta.color}`}>
|
|
{progress}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/8 bg-black/20 p-4 sm:p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className={`text-lg font-semibold ${meta.color}`}>
|
|
{meta.label}
|
|
</span>
|
|
{machineState !== 'error' && (
|
|
<span className="text-sm text-white/45">Secure pipeline active</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-white/8">
|
|
<motion.div
|
|
className={`h-full rounded-full bg-gradient-to-r ${meta.barColor}`}
|
|
animate={{ width: machineState === 'error' ? '100%' : `${progress}%` }}
|
|
transition={barTransition}
|
|
style={machineState === 'error' ? { opacity: 0.35 } : {}}
|
|
/>
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
{machineState !== 'error' && displayLabel && (
|
|
<motion.p
|
|
key={displayLabel}
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="mt-4 text-sm text-white/60"
|
|
>
|
|
{displayLabel}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{machineState !== 'error' && (
|
|
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-white/30">
|
|
Progress updates are live
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{machineState === 'error' && (
|
|
<motion.div
|
|
key="error-block"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={prefersReducedMotion ? {} : { opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-4">
|
|
<p className="text-sm leading-relaxed text-rose-100">
|
|
{error || 'Something went wrong. You can retry safely.'}
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="rounded-lg border border-rose-300/30 bg-rose-400/15 px-3.5 py-2 text-sm font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
|
|
>
|
|
Retry upload
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onReset}
|
|
className="rounded-lg border border-white/20 bg-white/8 px-3.5 py-2 text-sm font-medium text-white/70 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
|
>
|
|
Start over
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|