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:
219
resources/js/components/upload/PublishPanel.jsx
Normal file
219
resources/js/components/upload/PublishPanel.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import ReadinessChecklist from './ReadinessChecklist'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
/**
|
||||
* PublishPanel
|
||||
*
|
||||
* Right-sidebar panel (or mobile bottom-sheet) that shows:
|
||||
* - Thumbnail preview + title
|
||||
* - Status pill
|
||||
* - ReadinessChecklist
|
||||
* - Visibility selector
|
||||
* - Publish now / Schedule controls
|
||||
* - Primary action button
|
||||
*
|
||||
* Props mirror what UploadWizard collects.
|
||||
*/
|
||||
|
||||
const STATUS_PILL = {
|
||||
idle: null,
|
||||
initializing: { label: 'Uploading', cls: 'bg-sky-500/20 text-sky-200 border-sky-300/30' },
|
||||
uploading: { label: 'Uploading', cls: 'bg-sky-500/25 text-sky-100 border-sky-300/40' },
|
||||
finishing: { label: 'Processing', cls: 'bg-amber-500/20 text-amber-200 border-amber-300/30' },
|
||||
processing: { label: 'Processing', cls: 'bg-amber-500/20 text-amber-200 border-amber-300/30' },
|
||||
ready_to_publish: { label: 'Ready', cls: 'bg-emerald-500/20 text-emerald-100 border-emerald-300/35' },
|
||||
publishing: { label: 'Publishing…', cls: 'bg-sky-500/25 text-sky-100 border-sky-300/40' },
|
||||
complete: { label: 'Published', cls: 'bg-emerald-500/25 text-emerald-100 border-emerald-300/50' },
|
||||
scheduled: { label: 'Scheduled', cls: 'bg-violet-500/20 text-violet-200 border-violet-300/30' },
|
||||
error: { label: 'Error', cls: 'bg-red-500/20 text-red-200 border-red-300/30' },
|
||||
cancelled: { label: 'Cancelled', cls: 'bg-white/8 text-white/40 border-white/10' },
|
||||
}
|
||||
|
||||
export default function PublishPanel({
|
||||
// Asset
|
||||
primaryPreviewUrl = null,
|
||||
isArchive = false,
|
||||
screenshots = [],
|
||||
// Metadata
|
||||
metadata = {},
|
||||
// Readiness
|
||||
machineState = 'idle',
|
||||
uploadReady = false,
|
||||
canPublish = false,
|
||||
isPublishing = false,
|
||||
isArchiveRequiresScreenshot = false,
|
||||
// Publish options
|
||||
publishMode = 'now', // 'now' | 'schedule'
|
||||
scheduledAt = null,
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
visibility = 'public', // 'public' | 'unlisted' | 'private'
|
||||
onPublishModeChange,
|
||||
onScheduleAt,
|
||||
onVisibilityChange,
|
||||
onToggleRights,
|
||||
// Actions
|
||||
onPublish,
|
||||
onCancel,
|
||||
// Navigation helpers (for checklist quick-links)
|
||||
onGoToStep,
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
const hasAnyPreview = hasPreview || (isArchive && screenshots.length > 0)
|
||||
const previewSrc = hasPreview ? primaryPreviewUrl : (screenshots[0]?.preview ?? screenshots[0] ?? null)
|
||||
|
||||
const title = String(metadata.title || '').trim()
|
||||
const hasTitle = Boolean(title)
|
||||
const hasCategory = Boolean(metadata.rootCategoryId)
|
||||
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
|
||||
const hasRights = Boolean(metadata.rightsAccepted)
|
||||
const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0
|
||||
|
||||
const checklist = [
|
||||
{ label: 'File uploaded & processed', ok: uploadReady },
|
||||
{ label: 'Title', ok: hasTitle, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Category', ok: hasCategory, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Rights confirmed', ok: hasRights, onClick: () => onGoToStep?.(2) },
|
||||
...( isArchiveRequiresScreenshot
|
||||
? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }]
|
||||
: [] ),
|
||||
{ label: 'At least 1 tag', ok: hasTag, onClick: () => onGoToStep?.(2) },
|
||||
]
|
||||
|
||||
const publishLabel = useCallback(() => {
|
||||
if (isPublishing) return 'Publishing…'
|
||||
if (publishMode === 'schedule') return 'Schedule publish'
|
||||
return 'Publish now'
|
||||
}, [isPublishing, publishMode])
|
||||
|
||||
const canSchedulePublish =
|
||||
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
|
||||
|
||||
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
|
||||
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-5 space-y-5 h-fit">
|
||||
{/* Preview + title */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
|
||||
{previewSrc ? (
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt="Artwork preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
) : (
|
||||
<svg className="h-6 w-6 text-white/25" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + status */}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p className="truncate text-sm font-semibold text-white leading-snug">
|
||||
{hasTitle ? title : <span className="italic text-white/35">Untitled artwork</span>}
|
||||
</p>
|
||||
{pill && (
|
||||
<span className={`mt-1 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] ${pill.cls}`}>
|
||||
{['uploading', 'initializing', 'finishing', 'processing', 'publishing'].includes(machineState) && (
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
{pill.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/8" />
|
||||
|
||||
{/* Readiness checklist */}
|
||||
<ReadinessChecklist items={checklist} />
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-wider text-white/40 mb-1.5" htmlFor="publish-visibility">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="publish-visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => onVisibilityChange?.(e.target.value)}
|
||||
disabled={!canPublish && machineState !== 'ready_to_publish'}
|
||||
className="w-full appearance-none rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="private">Private (draft)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schedule picker – only shows when upload is ready */}
|
||||
{uploadReady && machineState !== 'complete' && (
|
||||
<SchedulePublishPicker
|
||||
mode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={timezone}
|
||||
onModeChange={onPublishModeChange}
|
||||
onScheduleAt={onScheduleAt}
|
||||
disabled={!canPublish || isPublishing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rights confirmation (required before publish) */}
|
||||
<div>
|
||||
<Checkbox
|
||||
id="publish-rights-confirm"
|
||||
checked={Boolean(metadata.rightsAccepted)}
|
||||
onChange={(event) => onToggleRights?.(event.target.checked)}
|
||||
variant="emerald"
|
||||
size={18}
|
||||
label={<span className="text-xs text-white/85">I confirm I own the rights to this content.</span>}
|
||||
hint={<span className="text-[11px] text-white/50">Required before publishing.</span>}
|
||||
error={rightsError}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSchedulePublish || isPublishing}
|
||||
onClick={() => onPublish?.()}
|
||||
title={!canPublish ? 'Complete all requirements first' : undefined}
|
||||
className={[
|
||||
'w-full rounded-xl py-2.5 text-sm font-semibold transition',
|
||||
canSchedulePublish && !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'
|
||||
: 'cursor-not-allowed bg-white/8 text-white/35 ring-1 ring-white/10',
|
||||
].join(' ')}
|
||||
>
|
||||
{publishLabel()}
|
||||
</button>
|
||||
|
||||
{/* Cancel link */}
|
||||
{onCancel && machineState !== 'idle' && machineState !== 'complete' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="w-full text-center text-xs text-white/35 hover:text-white/70 transition"
|
||||
>
|
||||
Cancel upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
resources/js/components/upload/ReadinessChecklist.jsx
Normal file
55
resources/js/components/upload/ReadinessChecklist.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* ReadinessChecklist
|
||||
*
|
||||
* Shows upload readiness requirements with status icons.
|
||||
* Each item can have an optional `href` to jump to the section for a quick fix.
|
||||
*/
|
||||
export default function ReadinessChecklist({ items = [] }) {
|
||||
const allOk = items.every((item) => item.ok)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] uppercase tracking-wider text-white/40">
|
||||
Readiness
|
||||
</p>
|
||||
<ul className="space-y-1" role="list">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={[
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold',
|
||||
item.ok
|
||||
? 'bg-emerald-500/25 text-emerald-300'
|
||||
: 'bg-white/8 text-white/30',
|
||||
].join(' ')}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.ok ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={item.ok ? 'text-white/70' : 'text-white/40'}>
|
||||
{(item.onClick || item.href) && !item.ok ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className="text-sky-400 hover:underline focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</span>
|
||||
{item.optional && !item.ok && (
|
||||
<span className="ml-auto text-[9px] text-white/25 italic">optional</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{allOk && (
|
||||
<p className="mt-2 text-[11px] text-emerald-300/80">All requirements met.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
resources/js/components/upload/SchedulePublishPicker.jsx
Normal file
219
resources/js/components/upload/SchedulePublishPicker.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* SchedulePublishPicker
|
||||
*
|
||||
* Toggle between "Publish now" and "Schedule publish".
|
||||
* When scheduled, shows a date + time input with validation
|
||||
* (must be >= now + 5 minutes).
|
||||
*
|
||||
* Props:
|
||||
* mode 'now' | 'schedule'
|
||||
* scheduledAt ISO string | null – current scheduled datetime (UTC)
|
||||
* timezone string – IANA tz (e.g. 'Europe/Ljubljana')
|
||||
* onModeChange (mode) => void
|
||||
* onScheduleAt (iso | null) => void
|
||||
* disabled bool
|
||||
*/
|
||||
function toLocalDateTimeString(isoString, tz) {
|
||||
if (!isoString) return { date: '', time: '' }
|
||||
try {
|
||||
const d = new Date(isoString)
|
||||
const opts = { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }
|
||||
const dateStr = new Intl.DateTimeFormat('en-CA', opts).format(d) // en-CA gives YYYY-MM-DD
|
||||
const timeStr = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d)
|
||||
return { date: dateStr, time: timeStr }
|
||||
} catch {
|
||||
return { date: '', time: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function formatPreviewLabel(isoString, tz) {
|
||||
if (!isoString) return null
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZoneName: 'short',
|
||||
}).format(new Date(isoString))
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
function localToUtcIso(dateStr, timeStr, tz) {
|
||||
if (!dateStr || !timeStr) return null
|
||||
try {
|
||||
const dtStr = `${dateStr}T${timeStr}:00`
|
||||
const local = new Date(
|
||||
new Date(dtStr).toLocaleString('en-US', { timeZone: tz })
|
||||
)
|
||||
const utcOffset = new Date(dtStr) - local
|
||||
const utcDate = new Date(new Date(dtStr).getTime() + utcOffset)
|
||||
return utcDate.toISOString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_FUTURE_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function SchedulePublishPicker({
|
||||
mode = 'now',
|
||||
scheduledAt = null,
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
onModeChange,
|
||||
onScheduleAt,
|
||||
disabled = false,
|
||||
}) {
|
||||
const initial = useMemo(
|
||||
() => toLocalDateTimeString(scheduledAt, timezone),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
const [dateStr, setDateStr] = useState(initial.date || '')
|
||||
const [timeStr, setTimeStr] = useState(initial.time || '')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const validate = useCallback(
|
||||
(d, t) => {
|
||||
if (!d || !t) return 'Date and time are required.'
|
||||
const iso = localToUtcIso(d, t, timezone)
|
||||
if (!iso) return 'Invalid date or time.'
|
||||
const target = new Date(iso)
|
||||
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
|
||||
if (target.getTime() - Date.now() < MIN_FUTURE_MS) {
|
||||
return 'Scheduled time must be at least 5 minutes in the future.'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
[timezone]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'schedule') {
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
if (!dateStr && !timeStr) {
|
||||
setError('')
|
||||
onScheduleAt?.(null)
|
||||
return
|
||||
}
|
||||
const err = validate(dateStr, timeStr)
|
||||
setError(err)
|
||||
if (!err) {
|
||||
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
|
||||
} else {
|
||||
onScheduleAt?.(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateStr, timeStr, mode])
|
||||
|
||||
const previewLabel = useMemo(() => {
|
||||
if (mode !== 'schedule' || error) return null
|
||||
const iso = localToUtcIso(dateStr, timeStr, timezone)
|
||||
return formatPreviewLabel(iso, timezone)
|
||||
}, [mode, error, dateStr, timeStr, timezone])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2" role="group" aria-label="Publish mode">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onModeChange?.('now')
|
||||
setError('')
|
||||
}}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm transition',
|
||||
mode === 'now'
|
||||
? 'border-sky-300/60 bg-sky-500/25 text-white'
|
||||
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
].join(' ')}
|
||||
aria-pressed={mode === 'now'}
|
||||
>
|
||||
Publish now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onModeChange?.('schedule')}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm transition',
|
||||
mode === 'schedule'
|
||||
? 'border-sky-300/60 bg-sky-500/25 text-white'
|
||||
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
].join(' ')}
|
||||
aria-pressed={mode === 'schedule'}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{previewLabel && (
|
||||
<p className="text-xs text-emerald-300/80">
|
||||
Will publish on: <span className="font-medium">{previewLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
resources/js/components/upload/StudioStatusBar.jsx
Normal file
141
resources/js/components/upload/StudioStatusBar.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
/**
|
||||
* StudioStatusBar
|
||||
*
|
||||
* Sticky header beneath the main nav that shows:
|
||||
* - Step pills (reuse UploadStepper visual style but condensed)
|
||||
* - Upload progress bar (visible while uploading/processing)
|
||||
* - Machine-state pill
|
||||
* - Back / Next primary actions
|
||||
*/
|
||||
const STATE_LABELS = {
|
||||
idle: null,
|
||||
initializing: 'Initializing…',
|
||||
uploading: 'Uploading',
|
||||
finishing: 'Finishing…',
|
||||
processing: 'Processing',
|
||||
ready_to_publish: 'Ready',
|
||||
publishing: 'Publishing…',
|
||||
complete: 'Published',
|
||||
error: 'Error',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const STATE_COLORS = {
|
||||
idle: '',
|
||||
initializing: 'bg-sky-500/20 text-sky-200 border-sky-300/30',
|
||||
uploading: 'bg-sky-500/25 text-sky-100 border-sky-300/40',
|
||||
finishing: 'bg-sky-400/20 text-sky-200 border-sky-300/30',
|
||||
processing: 'bg-amber-500/20 text-amber-100 border-amber-300/30',
|
||||
ready_to_publish: 'bg-emerald-500/20 text-emerald-100 border-emerald-300/35',
|
||||
publishing: 'bg-sky-500/25 text-sky-100 border-sky-300/40',
|
||||
complete: 'bg-emerald-500/25 text-emerald-100 border-emerald-300/50',
|
||||
error: 'bg-red-500/20 text-red-200 border-red-300/30',
|
||||
cancelled: 'bg-white/8 text-white/50 border-white/15',
|
||||
}
|
||||
|
||||
export default function StudioStatusBar({
|
||||
steps = [],
|
||||
activeStep = 1,
|
||||
highestUnlockedStep = 1,
|
||||
machineState = 'idle',
|
||||
progress = 0,
|
||||
showProgress = false,
|
||||
onStepClick,
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const transition = prefersReducedMotion ? { duration: 0 } : { duration: 0.3, ease: 'easeOut' }
|
||||
const stateLabel = STATE_LABELS[machineState] ?? machineState
|
||||
const stateColor = STATE_COLORS[machineState] ?? 'bg-white/8 text-white/50 border-white/15'
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-4 px-4 pt-2 pb-0 sm:-mx-6 sm:px-6">
|
||||
{/* Blur backdrop */}
|
||||
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
|
||||
|
||||
<div className="relative">
|
||||
{/* Step pills row */}
|
||||
<nav aria-label="Upload steps">
|
||||
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto py-3 pr-1 sm:gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const number = index + 1
|
||||
const isActive = number === activeStep
|
||||
const isComplete = number < activeStep
|
||||
const isLocked = number > highestUnlockedStep
|
||||
const canNavigate = !isLocked && number < activeStep
|
||||
|
||||
const btnClass = [
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] sm:text-xs transition',
|
||||
isActive
|
||||
? 'border-sky-300/70 bg-sky-500/25 text-white'
|
||||
: isComplete
|
||||
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25 cursor-pointer'
|
||||
: isLocked
|
||||
? 'cursor-default border-white/10 bg-white/5 text-white/35 pointer-events-none'
|
||||
: 'border-white/15 bg-white/6 text-white/70 hover:bg-white/12 cursor-pointer',
|
||||
].join(' ')
|
||||
|
||||
const circleClass = isComplete
|
||||
? 'border-emerald-300/50 bg-emerald-500/20 text-emerald-100'
|
||||
: isActive
|
||||
? 'border-sky-300/50 bg-sky-500/25 text-white'
|
||||
: 'border-white/20 bg-white/6 text-white/60'
|
||||
|
||||
return (
|
||||
<li key={step.key} className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canNavigate && onStepClick?.(number)}
|
||||
disabled={isLocked}
|
||||
aria-disabled={isLocked}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
className={btnClass}
|
||||
>
|
||||
<span className={`grid h-4 w-4 place-items-center rounded-full border text-[10px] shrink-0 ${circleClass}`}>
|
||||
{isComplete ? '✓' : number}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{step.label}</span>
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<span className="text-white/30 select-none text-xs" aria-hidden="true">›</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<li className="flex-1" aria-hidden="true" />
|
||||
|
||||
{/* State pill */}
|
||||
{stateLabel && (
|
||||
<li className="shrink-0">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] ${stateColor}`}>
|
||||
{['uploading', 'initializing', 'finishing', 'processing', 'publishing'].includes(machineState) && (
|
||||
<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-sky-300 opacity-60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-sky-300" />
|
||||
</span>
|
||||
)}
|
||||
{stateLabel}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Progress bar (shown during upload/processing) */}
|
||||
{showProgress && (
|
||||
<div className="h-0.5 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-cyan-300 to-emerald-300"
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={transition}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,187 @@
|
||||
import React from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
function ToolbarButton({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownEditor({ id, value, onChange, placeholder, error }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = selected ? start + replacement.length : start + before.length
|
||||
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const lines = (selected || '').split('\n')
|
||||
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + normalized + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + normalized.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
return (
|
||||
<div className={`mt-2 rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>• List</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>❝</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={5}
|
||||
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<p className="px-3 pb-2 text-[11px] text-white/45">
|
||||
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[132px] px-3 py-2">
|
||||
{String(value || '').trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{String(value || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
description = 'Complete metadata before publishing',
|
||||
@@ -44,15 +224,15 @@ export default function UploadSidebar({
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Description</span>
|
||||
<textarea
|
||||
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
|
||||
<MarkdownEditor
|
||||
id="upload-sidebar-description"
|
||||
value={metadata.description}
|
||||
onChange={(event) => onChangeDescription?.(event.target.value)}
|
||||
rows={5}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-300/70"
|
||||
onChange={onChangeDescription}
|
||||
placeholder="Describe your artwork (Markdown supported)."
|
||||
error={errors.description}
|
||||
/>
|
||||
{errors.description && <p className="mt-1 text-xs text-red-200">{errors.description}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UploadWizard from '../UploadWizard'
|
||||
|
||||
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false } = {}) {
|
||||
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false, finishError = null } = {}) {
|
||||
window.axios = {
|
||||
post: vi.fn((url, payload, config = {}) => {
|
||||
if (url === '/api/uploads/init') {
|
||||
@@ -44,6 +44,7 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/finish') {
|
||||
if (finishError) return Promise.reject(finishError)
|
||||
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
|
||||
}
|
||||
|
||||
@@ -281,6 +282,30 @@ describe('UploadWizard step flow', () => {
|
||||
expect((bar.className || '').includes('bottom-0')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows mapped duplicate hash toast when finish returns duplicate_hash', async () => {
|
||||
installAxiosStubs({
|
||||
finishError: {
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
reason: 'duplicate_hash',
|
||||
message: 'Duplicate upload is not allowed. This file already exists.',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await renderWizard({ initialDraftId: 310 })
|
||||
await uploadPrimary(new File(['img'], 'duplicate.png', { type: 'image/png' }))
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(toast.textContent).toMatch(/already exists in skinbase/i)
|
||||
})
|
||||
|
||||
it('locks step 1 file input after upload and unlocks after reset', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 309 })
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react'
|
||||
import UploadDropzone from '../UploadDropzone'
|
||||
import ScreenshotUploader from '../ScreenshotUploader'
|
||||
import UploadProgress from '../UploadProgress'
|
||||
import { machineStates } from '../../../hooks/upload/useUploadMachine'
|
||||
import { getProcessingTransparencyLabel } from '../../../lib/uploadUtils'
|
||||
|
||||
/**
|
||||
* Step1FileUpload
|
||||
@@ -28,24 +25,9 @@ export default function Step1FileUpload({
|
||||
screenshotErrors,
|
||||
screenshotPerFileErrors,
|
||||
onScreenshotsChange,
|
||||
// Machine state
|
||||
// Machine state (passed for potential future use)
|
||||
machine,
|
||||
showProgress,
|
||||
onRetry,
|
||||
onReset,
|
||||
}) {
|
||||
const processingTransparencyLabel = getProcessingTransparencyLabel(
|
||||
machine.processingStatus,
|
||||
machine.state
|
||||
)
|
||||
|
||||
const progressStatus = (() => {
|
||||
if (machine.state === machineStates.ready_to_publish) return 'Ready'
|
||||
if (machine.state === machineStates.uploading) return 'Uploading'
|
||||
if (machine.state === machineStates.processing || machine.state === machineStates.finishing) return 'Processing'
|
||||
return 'Idle'
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
|
||||
{/* Step header */}
|
||||
@@ -107,23 +89,6 @@ export default function Step1FileUpload({
|
||||
looksGoodText="Looks good"
|
||||
onFilesChange={onScreenshotsChange}
|
||||
/>
|
||||
|
||||
{/* Progress panel */}
|
||||
{showProgress && (
|
||||
<UploadProgress
|
||||
title="Upload progress"
|
||||
description="Upload and processing status"
|
||||
status={progressStatus}
|
||||
progress={machine.progress}
|
||||
state={machine.state}
|
||||
processingStatus={machine.processingStatus}
|
||||
isCancelling={machine.isCancelling}
|
||||
error={machine.error}
|
||||
processingLabel={processingTransparencyLabel}
|
||||
onRetry={onRetry}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ function PublishCheckBadge({ label, ok }) {
|
||||
* Step3Publish
|
||||
*
|
||||
* Step 3 of the upload wizard: review summary and publish action.
|
||||
* Shows a compact artwork preview, metadata summary, and readiness badges.
|
||||
* Shows a compact artwork preview, metadata summary, readiness badges,
|
||||
* and a summary of publish mode / schedule + visibility.
|
||||
*
|
||||
* Publish controls (mode/schedule picker) live in PublishPanel (sidebar).
|
||||
* This step serves as the final review before the user clicks Publish.
|
||||
*/
|
||||
export default function Step3Publish({
|
||||
headingRef,
|
||||
@@ -39,17 +43,36 @@ export default function Step3Publish({
|
||||
// Readiness
|
||||
canPublish,
|
||||
uploadReady,
|
||||
// Publish options (from wizard state, for summary display only)
|
||||
publishMode = 'now',
|
||||
scheduledAt = null,
|
||||
timezone = null,
|
||||
visibility = 'public',
|
||||
// Category tree (for label lookup)
|
||||
allRootCategoryOptions = [],
|
||||
filteredCategoryTree = [],
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
|
||||
// ── Category label lookup ────────────────────────────────────────────────
|
||||
const rootCategory = allRootCategoryOptions.find(
|
||||
(r) => String(r.id) === String(metadata.rootCategoryId)
|
||||
) ?? null
|
||||
const rootLabel = rootCategory?.name ?? null
|
||||
const subCategory = rootCategory?.children?.find(
|
||||
(c) => String(c.id) === String(metadata.subCategoryId)
|
||||
) ?? null
|
||||
const subLabel = subCategory?.name ?? null
|
||||
|
||||
const checks = [
|
||||
{ label: 'File uploaded', ok: uploadReady },
|
||||
{ label: 'Scan passed', ok: uploadReady },
|
||||
{ label: 'Preview ready', ok: hasPreview || (isArchive && screenshots.length > 0) },
|
||||
{ label: 'Rights confirmed', ok: Boolean(metadata.rightsAccepted) },
|
||||
{ label: 'Tags added', ok: Array.isArray(metadata.tags) && metadata.tags.length > 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -104,11 +127,11 @@ export default function Step3Publish({
|
||||
{metadata.contentType && (
|
||||
<span className="capitalize">Type: <span className="text-white/75">{metadata.contentType}</span></span>
|
||||
)}
|
||||
{metadata.rootCategoryId && (
|
||||
<span>Category: <span className="text-white/75">{metadata.rootCategoryId}</span></span>
|
||||
{rootLabel && (
|
||||
<span>Category: <span className="text-white/75">{rootLabel}</span></span>
|
||||
)}
|
||||
{metadata.subCategoryId && (
|
||||
<span>Sub: <span className="text-white/75">{metadata.subCategoryId}</span></span>
|
||||
{subLabel && (
|
||||
<span>Sub: <span className="text-white/75">{subLabel}</span></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -129,6 +152,32 @@ export default function Step3Publish({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publish summary: visibility + schedule */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/6 px-2.5 py-1 text-xs text-white/60">
|
||||
👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'}
|
||||
</span>
|
||||
{publishMode === 'schedule' && scheduledAt ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/30 bg-violet-500/15 px-2.5 py-1 text-xs text-violet-200">
|
||||
🕐 Scheduled
|
||||
{timezone && (
|
||||
<span className="text-violet-300/70">
|
||||
{' '}·{' '}
|
||||
{new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(new Date(scheduledAt))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/30 bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-200">
|
||||
⚡ Publish immediately
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Readiness badges */}
|
||||
<div>
|
||||
<p className="mb-2.5 text-xs uppercase tracking-wide text-white/40">Readiness checks</p>
|
||||
|
||||
Reference in New Issue
Block a user