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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user