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:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View File

@@ -15,8 +15,10 @@ import useUploadMachine, { machineStates } from '../../hooks/upload/useUploadMac
import useFileValidation from '../../hooks/upload/useFileValidation'
import useVisionTags from '../../hooks/upload/useVisionTags'
import UploadStepper from './UploadStepper'
import StudioStatusBar from './StudioStatusBar'
import UploadOverlay from './UploadOverlay'
import UploadActions from './UploadActions'
import PublishPanel from './PublishPanel'
import Step1FileUpload from './steps/Step1FileUpload'
import Step2Details from './steps/Step2Details'
import Step3Publish from './steps/Step3Publish'
@@ -24,6 +26,7 @@ import Step3Publish from './steps/Step3Publish'
import {
buildCategoryTree,
getContentTypeValue,
getProcessingTransparencyLabel,
} from '../../lib/uploadUtils'
// ─── Wizard step config ───────────────────────────────────────────────────────
@@ -59,6 +62,7 @@ export default function UploadWizard({
contentTypes = [],
suggestedTags = [],
}) {
const [notices, setNotices] = useState([])
// ── UI state ──────────────────────────────────────────────────────────────
const [activeStep, setActiveStep] = useState(1)
const [showRestoredBanner, setShowRestoredBanner] = useState(Boolean(initialDraftId))
@@ -68,6 +72,15 @@ export default function UploadWizard({
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
// ── Publish options (Studio) ──────────────────────────────────────────────
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
const userTimezone = useMemo(() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
}, [])
// ── File + screenshot state ───────────────────────────────────────────────
const [primaryFile, setPrimaryFile] = useState(null)
const [screenshots, setScreenshots] = useState([])
@@ -117,6 +130,17 @@ export default function UploadWizard({
metadata,
chunkSize,
onArtworkCreated: (id) => setResolvedArtworkId(id),
onNotice: (notice) => {
if (!notice?.message) return
const normalizedType = ['success', 'warning', 'error'].includes(String(notice.type || '').toLowerCase())
? String(notice.type).toLowerCase()
: 'error'
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
setNotices((prev) => [...prev, { id, type: normalizedType, message: String(notice.message) }])
window.setTimeout(() => {
setNotices((prev) => prev.filter((item) => item.id !== id))
}, 4500)
},
})
// ── Upload-ready flag (needed before vision hook) ─────────────────────────
@@ -177,12 +201,14 @@ export default function UploadWizard({
const metadataErrors = useMemo(() => {
const errors = {}
if (!String(metadata.title || '').trim()) errors.title = 'Title is required.'
if (!String(metadata.description || '').trim()) errors.description = 'Description is required.'
if (!metadata.contentType) errors.contentType = 'Content type is required.'
if (!metadata.rootCategoryId) errors.category = 'Root category is required.'
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
errors.category = 'Subcategory is required for the selected category.'
}
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
if (!Array.isArray(metadata.tags) || metadata.tags.length === 0) errors.tags = 'At least one tag is required.'
return errors
}, [metadata, requiresSubCategory])
@@ -212,6 +238,8 @@ export default function UploadWizard({
// ── Derived flags ─────────────────────────────────────────────────────────
const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1
const showProgress = ![machineStates.idle, machineStates.cancelled].includes(machine.state)
const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state)
const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state)
const canPublish = useMemo(() => (
uploadReady &&
@@ -219,11 +247,11 @@ export default function UploadWizard({
machine.state !== machineStates.publishing
), [uploadReady, metadata.rightsAccepted, machine.state])
const stepProgressPercent = useMemo(() => {
if (activeStep === 1) return 33
if (activeStep === 2) return 66
return 100
}, [activeStep])
const canScheduleSubmit = useMemo(() => {
if (!canPublish) return false
if (publishMode === 'schedule') return Boolean(scheduledAt)
return true
}, [canPublish, publishMode, scheduledAt])
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
@@ -269,7 +297,13 @@ export default function UploadWizard({
clearPolling()
}
}, [abortAllRequests, clearPolling])
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
useEffect(() => {
if (!showMobilePublishPanel) return
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [showMobilePublishPanel])
// ── Metadata helpers ──────────────────────────────────────────────────────
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
@@ -281,6 +315,10 @@ export default function UploadWizard({
setMetadata(initialMetadata)
setIsUploadLocked(false)
hasAutoAdvancedRef.current = false
setPublishMode('now')
setScheduledAt(null)
setVisibility('public')
setShowMobilePublishPanel(false)
setResolvedArtworkId(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
@@ -296,36 +334,45 @@ export default function UploadWizard({
const renderStepContent = () => {
// Complete / success screen
if (machine.state === machineStates.complete) {
const wasScheduled = machine.lastAction === 'schedule'
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.28, ease: 'easeOut' }}
className="rounded-2xl ring-1 ring-emerald-300/25 bg-emerald-500/8 p-8 text-center"
className={`rounded-2xl p-8 text-center ${wasScheduled ? 'ring-1 ring-violet-300/25 bg-violet-500/8' : 'ring-1 ring-emerald-300/25 bg-emerald-500/8'}`}
>
<motion.div
initial={prefersReducedMotion ? false : { scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { delay: 0.1, duration: 0.26, ease: 'backOut' }}
className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full ring-2 ring-emerald-300/40 bg-emerald-500/20 text-emerald-200"
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full text-2xl`}
>
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{wasScheduled ? '🕐' : '🎉'}
</motion.div>
<h3 className="text-xl font-semibold text-white">Your artwork is live 🎉</h3>
<p className="mt-2 text-sm text-emerald-100/75">
It has been published and is now visible to the community.
<h3 className="text-xl font-semibold text-white">
{wasScheduled ? 'Artwork scheduled!' : 'Your artwork is live!'}
</h3>
<p className="mt-2 text-sm text-white/65">
{wasScheduled
? scheduledAt
? `Will publish on ${new Intl.DateTimeFormat('en-GB', { timeZone: userTimezone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(scheduledAt))}`
: 'Your artwork is scheduled for future publishing.'
: 'It has been published and is now visible to the community.'}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a
href={resolvedArtworkId ? `/artwork/${resolvedArtworkId}` : '/'}
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
>
View artwork
</a>
<div className="mt-6 flex flex-wrap justify-center gap-3">
{!wasScheduled && (
<a
href={resolvedArtworkId
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
: '/'}
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
>
View artwork
</a>
)}
<button
type="button"
onClick={handleReset}
@@ -355,9 +402,6 @@ export default function UploadWizard({
screenshotPerFileErrors={screenshotPerFileErrors}
onScreenshotsChange={setScreenshots}
machine={machine}
showProgress={showProgress}
onRetry={() => handleRetry(canPublish)}
onReset={handleReset}
/>
)
}
@@ -400,6 +444,12 @@ export default function UploadWizard({
metadata={metadata}
canPublish={canPublish}
uploadReady={uploadReady}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree}
/>
)
}
@@ -407,7 +457,7 @@ export default function UploadWizard({
// ── Action bar helpers ────────────────────────────────────────────────────
const disableReason = (() => {
if (activeStep === 1) return validationErrors[0] || machine.error || 'Complete upload requirements first.'
if (activeStep === 2) return metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || 'Complete required metadata.'
if (activeStep === 2) return metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || metadataErrors.tags || 'Complete required metadata.'
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
@@ -415,9 +465,31 @@ export default function UploadWizard({
return (
<section
ref={stepContentRef}
className="space-y-6 pb-24 text-white lg:pb-0"
className="space-y-4 pb-32 text-white lg:pb-6"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
<div className="fixed right-4 top-4 z-[70] w-[min(92vw,420px)] space-y-2">
{notices.map((notice) => (
<div
key={notice.id}
role="alert"
aria-live="polite"
className={[
'rounded-xl border px-4 py-3 text-sm shadow-lg backdrop-blur',
notice.type === 'success'
? 'border-emerald-400/45 bg-emerald-500/12 text-emerald-100'
: notice.type === 'warning'
? 'border-amber-400/45 bg-amber-500/12 text-amber-100'
: 'border-red-400/45 bg-red-500/12 text-red-100',
].join(' ')}
>
{notice.message}
</div>
))}
</div>
)}
{/* Restored draft banner */}
{showRestoredBanner && (
<div className="rounded-xl ring-1 ring-sky-300/25 bg-sky-500/10 px-4 py-2.5 text-sm text-sky-100">
@@ -434,70 +506,192 @@ export default function UploadWizard({
</div>
)}
{/* Step indicator */}
<UploadStepper
{/* ── Studio Status Bar (sticky step header + progress) ────────────── */}
<StudioStatusBar
steps={wizardSteps}
activeStep={activeStep}
highestUnlockedStep={highestUnlockedStep}
machineState={machine.state}
progress={machine.progress}
showProgress={showProgress}
onStepClick={goToStep}
/>
{/* Thin progress bar */}
<div className="-mt-3 rounded-full bg-white/8 p-0.5">
<motion.div
className="h-1.5 rounded-full bg-gradient-to-r from-sky-400/90 via-cyan-300/90 to-emerald-300/90"
animate={{ width: `${stepProgressPercent}%` }}
transition={quickTransition}
/>
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
{/* Left / main column: step content */}
<div className="min-w-0 flex-1">
{/* Step content + floating progress overlay */}
<div className={`relative transition-[padding-bottom] duration-300 ${showOverlay ? 'pb-36' : ''}`}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`step-${activeStep}`}
initial={prefersReducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -8 }}
transition={quickTransition}
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
<UploadOverlay
machineState={machine.state}
progress={machine.progress}
processingLabel={processingLabel}
error={machine.error}
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onReset={handleReset}
/>
</div>
{/* Wizard action bar (nav: back/next/start/retry) */}
{machine.state !== machineStates.complete && (
<div className="mt-4">
<UploadActions
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canScheduleSubmit}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
canCancel={activeStep === 1 && [
machineStates.initializing,
machineStates.uploading,
machineStates.finishing,
machineStates.processing,
].includes(machine.state)}
canRetry={machine.state === machineStates.error}
isUploading={[machineStates.uploading, machineStates.initializing].includes(machine.state)}
isProcessing={[machineStates.processing, machineStates.finishing].includes(machine.state)}
isPublishing={machine.state === machineStates.publishing}
isCancelling={machine.isCancelling}
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onSaveDraft={() => {}}
showSaveDraft={activeStep === 2}
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
mobileSticky
/>
</div>
)}
</div>
{/* Right column: PublishPanel (sticky sidebar on lg+) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
<div className="hidden lg:block lg:w-72 xl:w-80 shrink-0 lg:sticky lg:top-20 lg:self-start">
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onCancel={handleCancel}
onGoToStep={goToStep}
/>
</div>
)}
</div>
{/* Animated step content */}
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`step-${activeStep}`}
initial={prefersReducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -8 }}
transition={quickTransition}
>
<div className="max-w-4xl mx-auto">
{renderStepContent()}
</div>
</motion.div>
</AnimatePresence>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-900/40 transition hover:bg-sky-400 active:scale-95"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Publish
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[...(!uploadReady ? [1] : []), ...(!metadata.title ? [1] : []), ...(!metadata.rightsAccepted ? [1] : [])].length}
</span>
)}
</button>
</div>
)}
{/* Sticky action bar */}
<UploadActions
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canPublish}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
canCancel={activeStep === 1 && [
machineStates.initializing,
machineStates.uploading,
machineStates.finishing,
machineStates.processing,
].includes(machine.state)}
canRetry={machine.state === machineStates.error}
isUploading={[machineStates.uploading, machineStates.initializing].includes(machine.state)}
isProcessing={[machineStates.processing, machineStates.finishing].includes(machine.state)}
isPublishing={machine.state === machineStates.publishing}
isCancelling={machine.isCancelling}
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish)}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
onRetry={() => handleRetry(canPublish)}
onSaveDraft={() => {}}
showSaveDraft={activeStep === 2}
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
mobileSticky
/>
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
<AnimatePresence>
{showMobilePublishPanel && (
<>
{/* Backdrop */}
<motion.div
key="mobile-panel-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setShowMobilePublishPanel(false)}
/>
{/* Sheet */}
<motion.div
key="mobile-panel-sheet"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
>
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })
}}
onCancel={() => {
setShowMobilePublishPanel(false)
handleCancel()
}}
onGoToStep={(s) => {
setShowMobilePublishPanel(false)
goToStep(s)
}}
/>
</motion.div>
</>
)}
</AnimatePresence>
</section>
)
}