Files
SkinbaseNova/resources/js/components/upload/UploadWizard.jsx
2026-03-28 19:15:39 +01:00

701 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UploadWizard refactored orchestrator
*
* A 3-step upload wizard that delegates:
* - Machine state → useUploadMachine
* - File validation → useFileValidation
* - Vision AI tags → useVisionTags
* - Step rendering → Step1FileUpload / Step2Details / Step3Publish
* - Reusable UI → ContentTypeSelector / CategorySelector / UploadSidebar …
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import useUploadMachine, { machineStates } from '../../hooks/upload/useUploadMachine'
import useFileValidation from '../../hooks/upload/useFileValidation'
import useVisionTags from '../../hooks/upload/useVisionTags'
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'
import {
buildCategoryTree,
getContentTypeValue,
getProcessingTransparencyLabel,
} from '../../lib/uploadUtils'
// ─── Wizard step config ───────────────────────────────────────────────────────
const wizardSteps = [
{ key: 'upload', label: 'Upload' },
{ key: 'details', label: 'Details' },
{ key: 'publish', label: 'Publish' },
]
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors) {
if (!primaryFile) return false
if (primaryErrors.length > 0) return false
if (isArchive && screenshotErrors.length > 0) return false
return true
}
const initialMetadata = {
title: '',
rootCategoryId: '',
subCategoryId: '',
tags: [],
description: '',
isMature: false,
rightsAccepted: false,
contentType: '',
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function UploadWizard({
onValidationStateChange,
initialDraftId = null,
chunkSize,
contentTypes = [],
suggestedTags = [],
}) {
const [notices, setNotices] = useState([])
// ── UI state ──────────────────────────────────────────────────────────────
const [activeStep, setActiveStep] = useState(1)
const [showRestoredBanner, setShowRestoredBanner] = useState(Boolean(initialDraftId))
const [isUploadLocked, setIsUploadLocked] = useState(false)
const [resolvedArtworkId, setResolvedArtworkId] = useState(() => {
const parsed = Number(initialDraftId)
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([])
// ── Metadata state ────────────────────────────────────────────────────────
const [metadata, setMetadata] = useState(initialMetadata)
// ── Refs ──────────────────────────────────────────────────────────────────
const prefersReducedMotion = useReducedMotion()
const stepContentRef = useRef(null)
const stepHeadingRef = useRef(null)
const hasAutoAdvancedRef = useRef(false)
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
// ── File validation hook ──────────────────────────────────────────────────
const {
primaryType,
primaryErrors,
primaryWarnings,
fileMetadata,
primaryPreviewUrl,
screenshotErrors,
screenshotPerFileErrors,
} = useFileValidation(primaryFile, screenshots)
const isArchive = primaryType === 'archive'
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
// ── Machine hook ──────────────────────────────────────────────────────────
const {
machine,
runUploadFlow,
handleCancel,
handlePublish,
handleRetry,
resetMachine,
abortAllRequests,
clearPolling,
} = useUploadMachine({
primaryFile,
canStartUpload,
primaryType,
isArchive,
initialDraftId,
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) ─────────────────────────
const uploadReady = (
machine.state === machineStates.ready_to_publish ||
machine.processingStatus === 'ready' ||
machine.state === machineStates.complete
)
// ── Vision tags hook fires on upload completion, not step change ──────────
// Starts fetching AI tag suggestions while the user is still on Step 1,
// so results are ready (or partially ready) by the time Step 2 opens.
const { visionSuggestedTags } = useVisionTags(resolvedArtworkId, uploadReady)
// ── Category tree computation ─────────────────────────────────────────────
const categoryTreeByType = useMemo(() => {
const result = {}
const list = Array.isArray(contentTypes) ? contentTypes : []
list.forEach((type) => {
const value = getContentTypeValue(type)
if (!value) return
result[value] = buildCategoryTree([type])
})
return result
}, [contentTypes])
const filteredCategoryTree = useMemo(() => {
const selected = String(metadata.contentType || '')
if (!selected) return []
return categoryTreeByType[selected] || []
}, [categoryTreeByType, metadata.contentType])
const allRootCategoryOptions = useMemo(() => {
const items = []
Object.entries(categoryTreeByType).forEach(([contentTypeValue, roots]) => {
roots.forEach((root) => items.push({ ...root, contentTypeValue }))
})
return items
}, [categoryTreeByType])
const selectedRootCategory = useMemo(() => {
const id = String(metadata.rootCategoryId || '')
if (!id) return null
return (
filteredCategoryTree.find((r) => String(r.id) === id) ||
allRootCategoryOptions.find((r) => String(r.id) === id) ||
null
)
}, [filteredCategoryTree, allRootCategoryOptions, metadata.rootCategoryId])
const requiresSubCategory = Boolean(
selectedRootCategory &&
Array.isArray(selectedRootCategory.children) &&
selectedRootCategory.children.length > 0
)
// ── Metadata validation ───────────────────────────────────────────────────
const metadataErrors = useMemo(() => {
const errors = {}
if (!String(metadata.title || '').trim()) errors.title = 'Title 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.'
return errors
}, [metadata, requiresSubCategory])
const detailsValid = Object.keys(metadataErrors).length === 0
// ── Merged AI + manual suggested tags ────────────────────────────────────
const mergedSuggestedTags = useMemo(() => {
const map = new Map()
const addTag = (item) => {
if (!item) return
const key = String(item?.slug || item?.tag || item?.name || item).trim().toLowerCase()
if (!key || map.has(key)) return
map.set(key, typeof item === 'string' ? item : {
id: item.id ?? key,
name: item.name || item.tag || item.slug || key,
slug: item.slug || item.tag || key,
usage_count: Number(item.usage_count || 0),
is_ai: Boolean(item.is_ai || item.source === 'ai'),
source: item.source || (item.is_ai ? 'ai' : 'manual'),
})
}
;(Array.isArray(suggestedTags) ? suggestedTags : []).forEach(addTag)
;(Array.isArray(visionSuggestedTags) ? visionSuggestedTags : []).forEach(addTag)
return Array.from(map.values())
}, [suggestedTags, visionSuggestedTags])
// ── 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 &&
metadata.rightsAccepted &&
machine.state !== machineStates.publishing
), [uploadReady, metadata.rightsAccepted, machine.state])
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(
() => [...primaryErrors, ...screenshotErrors],
[primaryErrors, screenshotErrors]
)
useEffect(() => {
if (typeof onValidationStateChange === 'function') {
onValidationStateChange({ isValid: canStartUpload, validationErrors, isArchive })
}
}, [canStartUpload, validationErrors, isArchive, onValidationStateChange])
// ── Auto-advance to step 2 after upload complete ──────────────────────────
useEffect(() => {
if (uploadReady && activeStep === 1 && !hasAutoAdvancedRef.current) {
hasAutoAdvancedRef.current = true
setIsUploadLocked(true)
setActiveStep(2)
}
}, [uploadReady, activeStep])
useEffect(() => {
if (uploadReady) setIsUploadLocked(true)
}, [uploadReady])
// ── Step scroll + focus ───────────────────────────────────────────────────
useEffect(() => {
if (!stepContentRef.current) return
stepContentRef.current.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
})
window.setTimeout(() => {
stepHeadingRef.current?.focus?.({ preventScroll: true })
}, 0)
}, [activeStep, prefersReducedMotion])
// ── Cleanup ───────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
abortAllRequests()
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 })), [])
// ── Full reset ────────────────────────────────────────────────────────────
const handleReset = useCallback(() => {
resetMachine()
setPrimaryFile(null)
setScreenshots([])
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
})
setActiveStep(1)
}, [resetMachine, initialDraftId])
const goToStep = useCallback((step) => {
if (step >= 1 && step <= highestUnlockedStep) setActiveStep(step)
}, [highestUnlockedStep])
// ── Step content renderer ─────────────────────────────────────────────────
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 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 text-2xl`}
>
{wasScheduled ? '🕐' : '🎉'}
</motion.div>
<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">
{!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}
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm text-white hover:bg-white/15 transition"
>
Upload another
</button>
</div>
</motion.div>
)
}
if (activeStep === 1) {
return (
<Step1FileUpload
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
primaryErrors={primaryErrors}
primaryWarnings={primaryWarnings}
fileMetadata={fileMetadata}
fileSelectionLocked={isUploadLocked}
onPrimaryFileChange={setPrimaryFile}
isArchive={isArchive}
screenshots={screenshots}
screenshotErrors={screenshotErrors}
screenshotPerFileErrors={screenshotPerFileErrors}
onScreenshotsChange={setScreenshots}
machine={machine}
/>
)
}
if (activeStep === 2) {
return (
<Step2Details
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
fileMetadata={fileMetadata}
screenshots={screenshots}
contentTypes={contentTypes}
metadata={metadata}
metadataErrors={metadataErrors}
filteredCategoryTree={filteredCategoryTree}
allRootCategoryOptions={allRootCategoryOptions}
requiresSubCategory={requiresSubCategory}
onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '' })}
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
suggestedTags={mergedSuggestedTags}
onChangeTitle={(value) => setMeta({ title: value })}
onChangeTags={(value) => setMeta({ tags: value })}
onChangeDescription={(value) => setMeta({ description: value })}
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
/>
)
}
return (
<Step3Publish
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
fileMetadata={fileMetadata}
metadata={metadata}
canPublish={canPublish}
uploadReady={uploadReady}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree}
/>
)
}
// ── 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 || metadataErrors.tags || 'Complete required metadata.'
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
// ─────────────────────────────────────────────────────────────────────────
return (
<section
ref={stepContentRef}
className="space-y-5 pb-32 text-white lg:pb-8"
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-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-3 text-sm text-sky-100 shadow-[0_14px_44px_rgba(14,165,233,0.10)]">
<div className="flex items-center justify-between gap-3">
<span>Draft restored. Continue from your previous upload session.</span>
<button
type="button"
onClick={() => setShowRestoredBanner(false)}
className="shrink-0 rounded-md ring-1 ring-sky-200/35 bg-sky-500/15 px-2 py-1 text-xs text-sky-100 hover:bg-sky-500/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
Dismiss
</button>
</div>
</div>
)}
{/* ── Studio Status Bar (sticky step header + progress) ────────────── */}
<StudioStatusBar
steps={wizardSteps}
activeStep={activeStep}
highestUnlockedStep={highestUnlockedStep}
machineState={machine.state}
progress={machine.progress}
showProgress={showProgress}
onStepClick={goToStep}
/>
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-8">
{/* Left / main column: step content */}
<div className="min-w-0 flex-1">
{/* Step content + centered progress overlay */}
<div className="relative">
<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-5">
<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+, Step 2+ only) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] 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}
showRightsConfirmation={activeStep === 3}
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>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
aria-label="Open publish panel"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] 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>
)}
{/* ── 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}
showRightsConfirmation={activeStep === 3}
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>
)
}