Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -66,6 +66,8 @@ export default function PublishPanel({
allRootCategoryOptions = [],
actionLabel = 'Publish now',
showScheduleControls = true,
publishActionEnabled = true,
publishActionTitle = 'Complete all requirements first',
}) {
const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
@@ -103,6 +105,7 @@ export default function PublishPanel({
const canSchedulePublish =
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
const canTriggerPublish = publishActionEnabled && canSchedulePublish
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
@@ -257,12 +260,12 @@ export default function PublishPanel({
{/* Primary action button */}
<button
type="button"
disabled={!canSchedulePublish || isPublishing}
disabled={!canTriggerPublish || isPublishing}
onClick={() => onPublish?.()}
title={!canPublish ? 'Complete all requirements first' : undefined}
title={!publishActionEnabled ? publishActionTitle : !canPublish ? 'Complete all requirements first' : undefined}
className={[
'w-full rounded-2xl py-3 text-sm font-semibold transition',
canSchedulePublish && !isPublishing
canTriggerPublish && !isPublishing
? publishMode === 'schedule'
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
: 'btn-primary'

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import DateTimePicker from '../ui/DateTimePicker'
/**
* SchedulePublishPicker
@@ -82,14 +83,18 @@ export default function SchedulePublishPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const [dateStr, setDateStr] = useState(initial.date || '')
const [timeStr, setTimeStr] = useState(initial.time || '')
const [localDateTime, setLocalDateTime] = useState(initial.date && initial.time ? `${initial.date}T${initial.time}` : '')
const [error, setError] = useState('')
const minScheduleLocalDateTime = (() => {
const next = toLocalDateTimeString(new Date(Date.now() + MIN_FUTURE_MS).toISOString(), timezone)
return next.date && next.time ? `${next.date}T${next.time}` : ''
})()
const validate = useCallback(
(d, t) => {
if (!d || !t) return 'Date and time are required.'
const iso = localToUtcIso(d, t, timezone)
(value) => {
const [datePart = '', timePart = ''] = String(value || '').split('T')
if (!datePart || !timePart) return 'Date and time are required.'
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
if (!iso) return 'Invalid date or time.'
const target = new Date(iso)
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
@@ -101,31 +106,38 @@ export default function SchedulePublishPicker({
[timezone]
)
useEffect(() => {
const next = toLocalDateTimeString(scheduledAt, timezone)
setLocalDateTime(next.date && next.time ? `${next.date}T${next.time}` : '')
}, [scheduledAt, timezone])
useEffect(() => {
if (mode !== 'schedule') {
setError('')
return
}
if (!dateStr && !timeStr) {
if (!localDateTime) {
setError('')
onScheduleAt?.(null)
return
}
const err = validate(dateStr, timeStr)
const err = validate(localDateTime)
setError(err)
if (!err) {
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
const [datePart = '', timePart = ''] = localDateTime.split('T')
onScheduleAt?.(localToUtcIso(datePart, timePart.slice(0, 5), timezone))
} else {
onScheduleAt?.(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateStr, timeStr, mode])
}, [localDateTime, mode, timezone])
const previewLabel = useMemo(() => {
if (mode !== 'schedule' || error) return null
const iso = localToUtcIso(dateStr, timeStr, timezone)
const [datePart = '', timePart = ''] = localDateTime.split('T')
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
return formatPreviewLabel(iso, timezone)
}, [mode, error, dateStr, timeStr, timezone])
}, [mode, error, localDateTime, timezone])
return (
<div className="space-y-3">
@@ -167,45 +179,18 @@ export default function SchedulePublishPicker({
{mode === 'schedule' && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex-1">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
Date
</label>
<input
id="schedule-date"
type="date"
disabled={disabled}
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
min={new Date().toISOString().slice(0, 10)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
<div className="w-28 shrink-0">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
Time
</label>
<input
id="schedule-time"
type="time"
disabled={disabled}
value={timeStr}
onChange={(e) => setTimeStr(e.target.value)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
</div>
<p className="text-[10px] text-white/35">
Timezone: <span className="text-white/55">{timezone}</span>
</p>
{error && (
<p className="text-xs text-red-400" role="alert">
{error}
</p>
)}
<DateTimePicker
id="schedule-datetime"
label="Release date and time"
value={localDateTime}
onChange={setLocalDateTime}
placeholder="Pick a release slot"
disabled={disabled}
minDateTime={minScheduleLocalDateTime}
clearable
hint={`Timezone: ${timezone}`}
error={error}
/>
{previewLabel && (
<p className="text-xs text-emerald-300/80">

View File

@@ -92,8 +92,8 @@ export default function UploadActions({
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'pointer-events-none fixed inset-x-0 bottom-0 z-[70] px-4 pb-4 pt-3' : ''}`}>
<div className="pointer-events-auto mx-auto w-full max-w-7xl rounded-[24px] border border-white/10 bg-[#08111c]/92 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}

View File

@@ -130,7 +130,6 @@ export default function UploadWizard({
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' }
}, [])
@@ -393,6 +392,8 @@ export default function UploadWizard({
return true
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
const publishActionEnabled = activeStep === 3 && canScheduleSubmit
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
() => [...primaryErrors, ...screenshotErrors],
@@ -437,13 +438,6 @@ 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 })), [])
@@ -459,7 +453,6 @@ export default function UploadWizard({
setPublishMode('now')
setScheduledAt(null)
setVisibility('public')
setShowMobilePublishPanel(false)
setResolvedArtworkId(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
@@ -705,11 +698,15 @@ export default function UploadWizard({
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
const publishActionTitle = activeStep < 3
? 'Continue to the final publish step to choose Worlds and publish.'
: disableReason
// ─────────────────────────────────────────────────────────────────────────
return (
<section
ref={stepContentRef}
className="space-y-5 pb-32 text-white lg:pb-8"
className="space-y-5 pb-40 text-white lg:pb-40"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
@@ -796,7 +793,7 @@ export default function UploadWizard({
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canScheduleSubmit}
canPublish={publishActionEnabled}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
canCancel={activeStep === 1 && [
@@ -813,7 +810,7 @@ export default function UploadWizard({
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onPublish={() => publishActionEnabled && handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
@@ -841,6 +838,8 @@ export default function UploadWizard({
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
publishActionEnabled={publishActionEnabled}
publishActionTitle={publishActionTitle}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
@@ -864,101 +863,6 @@ export default function UploadWizard({
)}
</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>
{reviewSubmissionMode ? 'Review' : 'Publish'}
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[
...(!uploadReady ? [1] : []),
...(hasTitle ? [] : [1]),
...(hasCompleteCategory ? [] : [1]),
...(hasTag ? [] : [1]),
...(hasRequiredScreenshot ? [] : [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}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
}}
onCancel={() => {
setShowMobilePublishPanel(false)
handleCancel()
}}
onGoToStep={(s) => {
setShowMobilePublishPanel(false)
goToStep(s)
}}
allRootCategoryOptions={allRootCategoryOptions}
/>
</motion.div>
</>
)}
</AnimatePresence>
</section>
)
}

View File

@@ -478,12 +478,12 @@ describe('UploadWizard step flow', () => {
expect(studioEditLink.getAttribute('href')).toBe('/studio/artworks/315/edit')
})
it('keeps mobile sticky action bar visible class', async () => {
it('keeps the action bar fixed to the bottom', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })
const bar = screen.getByTestId('wizard-action-bar')
expect((bar.className || '').includes('sticky')).toBe(true)
expect((bar.className || '').includes('fixed')).toBe(true)
expect((bar.className || '').includes('bottom-0')).toBe(true)
})

View File

@@ -229,6 +229,7 @@ export default function Step3Publish({
options={eligibleWorlds}
onToggle={onToggleWorldSubmission}
onNoteChange={onChangeWorldSubmissionNote}
analyticsContext={{ sourceSurface: 'upload_flow', sourceDetail: 'publish_step' }}
/>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">