feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
217
resources/js/components/upload/UploadOverlay.jsx
Normal file
217
resources/js/components/upload/UploadOverlay.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
/**
|
||||
* UploadOverlay
|
||||
*
|
||||
* A frosted-glass floating panel that rises from the bottom of the step content
|
||||
* area 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"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`${meta.label}${progress > 0 ? ` — ${progress}%` : ''}`}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 24, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: 16, scale: 0.98 }}
|
||||
transition={overlayTransition}
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none"
|
||||
>
|
||||
{/* Fade-out gradient so step content peeks through above */}
|
||||
<div
|
||||
className="absolute inset-x-0 -top-12 h-12 bg-gradient-to-t from-slate-950/70 to-transparent pointer-events-none rounded-b-2xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-auto mx-0 rounded-b-2xl rounded-t-xl border border-white/10 bg-slate-950/88 px-5 pb-5 pt-4 shadow-2xl shadow-black/70 ring-1 ring-inset ring-white/6 backdrop-blur-xl">
|
||||
{/* ── Header: icon + state label + percentage ── */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={`flex items-center gap-2 ${meta.color}`}>
|
||||
{meta.icon}
|
||||
<span className="text-sm font-semibold tracking-wide">
|
||||
{meta.label}
|
||||
</span>
|
||||
{/* Pulsing dot for active states */}
|
||||
{machineState !== 'error' && (
|
||||
<span className="relative flex h-2 w-2 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 w-2 rounded-full bg-current opacity-80" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{machineState !== 'error' && (
|
||||
<span className={`tabular-nums text-sm font-bold ${meta.color}`}>
|
||||
{progress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Progress bar ── */}
|
||||
<div className="mt-3 h-2 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>
|
||||
|
||||
{/* ── Sublabel / transparency message ── */}
|
||||
<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-2 text-xs text-white/50"
|
||||
>
|
||||
{displayLabel}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Error details + actions ── */}
|
||||
<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-3 rounded-lg border border-rose-400/20 bg-rose-500/10 px-3 py-2.5">
|
||||
<p className="text-xs text-rose-200 leading-relaxed">
|
||||
{error || 'Something went wrong. You can retry safely.'}
|
||||
</p>
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-md border border-rose-300/30 bg-rose-400/15 px-3 py-1 text-xs 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-md border border-white/20 bg-white/8 px-3 py-1 text-xs font-medium text-white/60 transition hover:bg-white/14 hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user