Files
SkinbaseNova/resources/js/components/upload/PublishPanel.jsx
Gregor Klevze 979e011257 Refactor dashboard and upload flows
Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
2026-03-21 11:02:22 +01:00

261 lines
10 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.
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'
showRightsConfirmation = true,
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
const visibilityOptions = [
{
value: 'public',
label: 'Public',
hint: 'Visible to everyone',
},
{
value: 'unlisted',
label: 'Unlisted',
hint: 'Available by direct link',
},
{
value: 'private',
label: 'Private',
hint: 'Keep as draft visibility',
},
]
return (
<div className="h-fit space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur">
{/* 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 */}
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
<ReadinessChecklist items={checklist} />
</div>
{/* Visibility */}
<div>
<label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility">
Visibility
</label>
<div id="publish-visibility" className="grid gap-2">
{visibilityOptions.map((option) => {
const active = visibility === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onVisibilityChange?.(option.value)}
disabled={!canPublish && machineState !== 'ready_to_publish'}
className={[
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition disabled:opacity-50',
active
? 'border-sky-300/30 bg-sky-400/10 text-white'
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs text-white/50">{option.hint}</div>
</div>
<span className={[
'mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]',
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/35',
].join(' ')}>
{active ? '✓' : ''}
</span>
</button>
)
})}
</div>
</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}
/>
)}
{showRightsConfirmation && (
<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-2xl py-3 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>
)
}