Profile: store covers in object storage (WebP); add covers config; remember artworks categories content-type preference
This commit is contained in:
@@ -6,6 +6,7 @@ import TextInput from '../../components/ui/TextInput'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
import FormField from '../../components/ui/FormField'
|
||||
import Toggle from '../../components/ui/Toggle'
|
||||
import TagPicker from '../../components/tags/TagPicker'
|
||||
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
|
||||
|
||||
|
||||
@@ -609,11 +609,11 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
||||
if (uploadsV2Enabled) {
|
||||
return (
|
||||
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
|
||||
<div className="relative isolate overflow-hidden">
|
||||
<div className="relative isolate">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
{/* ── Wizard ─────────────────────────────────────────────────────── */}
|
||||
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
|
||||
<div className="rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
|
||||
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
|
||||
<UploadWizard
|
||||
initialDraftId={draftId ?? null}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function PublishPanel({
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
visibility = 'public', // 'public' | 'unlisted' | 'private'
|
||||
showRightsConfirmation = true,
|
||||
showVisibility = false,
|
||||
onPublishModeChange,
|
||||
onScheduleAt,
|
||||
onVisibilityChange,
|
||||
@@ -59,6 +60,7 @@ export default function PublishPanel({
|
||||
onCancel,
|
||||
// Navigation helpers (for checklist quick-links)
|
||||
onGoToStep,
|
||||
allRootCategoryOptions = [],
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
@@ -67,7 +69,13 @@ export default function PublishPanel({
|
||||
|
||||
const title = String(metadata.title || '').trim()
|
||||
const hasTitle = Boolean(title)
|
||||
const hasCategory = Boolean(metadata.rootCategoryId)
|
||||
const selectedRoot = allRootCategoryOptions.find(
|
||||
(item) => String(item.id) === String(metadata.rootCategoryId || '')
|
||||
) ?? null
|
||||
const requiresSubCategory = Boolean(selectedRoot?.children?.length)
|
||||
const hasCompleteCategory = Boolean(
|
||||
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
|
||||
)
|
||||
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
|
||||
const hasRights = Boolean(metadata.rightsAccepted)
|
||||
const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0
|
||||
@@ -75,7 +83,7 @@ export default function PublishPanel({
|
||||
const checklist = [
|
||||
{ label: 'File uploaded & processed', ok: uploadReady },
|
||||
{ label: 'Title', ok: hasTitle, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Category', ok: hasCategory, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Category', ok: hasCompleteCategory, onClick: () => onGoToStep?.(2) },
|
||||
{ label: 'Rights confirmed', ok: hasRights, onClick: () => onGoToStep?.(2) },
|
||||
...( isArchiveRequiresScreenshot
|
||||
? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }]
|
||||
@@ -162,7 +170,8 @@ export default function PublishPanel({
|
||||
<ReadinessChecklist items={checklist} />
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
{/* Visibility (only when showVisibility=true) */}
|
||||
{showVisibility && (
|
||||
<div>
|
||||
<label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility">
|
||||
Visibility
|
||||
@@ -198,9 +207,10 @@ export default function PublishPanel({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule picker – only shows when upload is ready */}
|
||||
{uploadReady && machineState !== 'complete' && (
|
||||
{/* Schedule picker – only shows when enabled for this panel */}
|
||||
{showVisibility && uploadReady && machineState !== 'complete' && (
|
||||
<SchedulePublishPicker
|
||||
mode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import RichTextEditor from '../forum/RichTextEditor'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
@@ -10,6 +11,11 @@ export default function UploadSidebar({
|
||||
metadata,
|
||||
suggestedTags = [],
|
||||
errors = {},
|
||||
publishMode,
|
||||
scheduledAt,
|
||||
timezone,
|
||||
onPublishModeChange,
|
||||
onScheduleAt,
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
@@ -17,7 +23,7 @@ export default function UploadSidebar({
|
||||
onToggleRights,
|
||||
}) {
|
||||
return (
|
||||
<aside className="rounded-[28px] border border-white/8 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_18px_60px_rgba(0,0,0,0.22)] sm:p-7">
|
||||
<div className="space-y-5">
|
||||
{showHeader && (
|
||||
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
@@ -77,6 +83,24 @@ export default function UploadSidebar({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{typeof publishMode === 'string' && typeof onPublishModeChange === 'function' && (
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-white">Publish settings</h4>
|
||||
<p className="mt-1 text-xs text-white/60">Choose whether this artwork should publish immediately or on a schedule.</p>
|
||||
</div>
|
||||
|
||||
<SchedulePublishPicker
|
||||
mode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={timezone}
|
||||
onModeChange={onPublishModeChange}
|
||||
onScheduleAt={onScheduleAt}
|
||||
disabled={false}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<Checkbox
|
||||
id="upload-sidebar-mature"
|
||||
@@ -103,6 +127,6 @@ export default function UploadSidebar({
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -240,11 +240,22 @@ export default function UploadWizard({
|
||||
const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state)
|
||||
const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state)
|
||||
|
||||
const hasTitle = Boolean(String(metadata.title || '').trim())
|
||||
const hasCompleteCategory = Boolean(
|
||||
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
|
||||
)
|
||||
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
|
||||
const hasRequiredScreenshot = !isArchive || screenshots.length > 0
|
||||
|
||||
const canPublish = useMemo(() => (
|
||||
uploadReady &&
|
||||
hasTitle &&
|
||||
hasCompleteCategory &&
|
||||
hasTag &&
|
||||
hasRequiredScreenshot &&
|
||||
metadata.rightsAccepted &&
|
||||
machine.state !== machineStates.publishing
|
||||
), [uploadReady, metadata.rightsAccepted, machine.state])
|
||||
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state])
|
||||
|
||||
const canScheduleSubmit = useMemo(() => {
|
||||
if (!canPublish) return false
|
||||
@@ -424,6 +435,11 @@ export default function UploadWizard({
|
||||
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
|
||||
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
|
||||
suggestedTags={mergedSuggestedTags}
|
||||
publishMode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onChangeTitle={(value) => setMeta({ title: value })}
|
||||
onChangeTags={(value) => setMeta({ tags: value })}
|
||||
onChangeDescription={(value) => setMeta({ description: value })}
|
||||
@@ -448,6 +464,7 @@ export default function UploadWizard({
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
onVisibilityChange={setVisibility}
|
||||
allRootCategoryOptions={allRootCategoryOptions}
|
||||
filteredCategoryTree={filteredCategoryTree}
|
||||
/>
|
||||
@@ -601,6 +618,7 @@ export default function UploadWizard({
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
showRightsConfirmation={activeStep === 3}
|
||||
showVisibility={false}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
@@ -608,6 +626,7 @@ export default function UploadWizard({
|
||||
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
|
||||
onCancel={handleCancel}
|
||||
onGoToStep={goToStep}
|
||||
allRootCategoryOptions={allRootCategoryOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -628,7 +647,14 @@ export default function UploadWizard({
|
||||
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}
|
||||
{[
|
||||
...(!uploadReady ? [1] : []),
|
||||
...(hasTitle ? [] : [1]),
|
||||
...(hasCompleteCategory ? [] : [1]),
|
||||
...(hasTag ? [] : [1]),
|
||||
...(hasRequiredScreenshot ? [] : [1]),
|
||||
...(metadata.rightsAccepted ? [] : [1]),
|
||||
].length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -674,6 +700,7 @@ export default function UploadWizard({
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
showRightsConfirmation={activeStep === 3}
|
||||
showVisibility={false}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
@@ -690,6 +717,7 @@ export default function UploadWizard({
|
||||
setShowMobilePublishPanel(false)
|
||||
goToStep(s)
|
||||
}}
|
||||
allRootCategoryOptions={allRootCategoryOptions}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
|
||||
@@ -29,6 +29,11 @@ export default function Step2Details({
|
||||
onSubCategoryChange,
|
||||
// Sidebar (title / tags / description / rights)
|
||||
suggestedTags,
|
||||
publishMode,
|
||||
scheduledAt,
|
||||
timezone,
|
||||
onPublishModeChange,
|
||||
onScheduleAt,
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
@@ -164,247 +169,244 @@ export default function Step2Details({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.08),_rgba(15,23,36,0.92)_52%)] p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
|
||||
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(ellipse_at_top_left,_rgba(14,165,233,0.07),_transparent_45%),radial-gradient(ellipse_at_bottom_right,_rgba(168,85,247,0.07),_transparent_45%)] p-5 sm:p-6">
|
||||
{/* Section header */}
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Content type</h3>
|
||||
<p className="mt-1 text-xs text-white/55">Choose the main content family first.</p>
|
||||
<h3 className="text-sm font-semibold text-white">Content type & category</h3>
|
||||
<p className="mt-1 text-xs text-white/55">Choose the content family, then narrow down to a category and subcategory.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">
|
||||
Step 2a
|
||||
</span>
|
||||
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">Step 2</span>
|
||||
</div>
|
||||
|
||||
{contentTypeOptions.length === 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">
|
||||
No content types are available right now.
|
||||
</div>
|
||||
)}
|
||||
{/* ── Content type ── */}
|
||||
<div>
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Content type</p>
|
||||
|
||||
{selectedContentType && !isContentTypeChooserOpen && (
|
||||
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Selected content type</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{selectedContentType.name}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{filteredCategoryTree.length > 0
|
||||
? `Continue by choosing one of the ${filteredCategoryTree.length} matching categories below.`
|
||||
: 'This content type does not have categories yet.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsContentTypeChooserOpen(true)}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
{contentTypeOptions.length === 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">
|
||||
No content types are available right now.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{(!selectedContentType || isContentTypeChooserOpen) && (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{contentTypeOptions.map((ct) => {
|
||||
const typeValue = String(getContentTypeValue(ct))
|
||||
const isActive = typeValue === String(metadata.contentType || '')
|
||||
const visualKey = getContentTypeVisualKey(ct)
|
||||
const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={typeValue || ct.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsContentTypeChooserOpen(false)
|
||||
setIsCategoryChooserOpen(true)
|
||||
onContentTypeChange(typeValue)
|
||||
}}
|
||||
className={[
|
||||
'group flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
isActive
|
||||
? 'border-emerald-400/40 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.18)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${isActive ? 'border-emerald-400/30 bg-emerald-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||
<img
|
||||
src={`/gfx/mascot_${visualKey}.webp`}
|
||||
alt=""
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-sm font-semibold ${isActive ? 'text-emerald-200' : 'text-white'}`}>{ct.name}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}</div>
|
||||
</div>
|
||||
<div className={`text-xs ${isActive ? 'text-emerald-300' : 'text-slate-500 group-hover:text-slate-300'}`}>
|
||||
{isActive ? 'Selected' : 'Open'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(168,85,247,0.08),_rgba(15,23,36,0.88)_55%)] p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Category path</h4>
|
||||
<p className="mt-1 text-sm text-slate-400">Choose the main branch first, then refine with a subcategory when needed.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
|
||||
Step 2b
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!selectedContentType && (
|
||||
<div className="mt-5 rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center">
|
||||
<div className="text-sm font-medium text-white">Select a content type first</div>
|
||||
<p className="mt-2 text-sm text-slate-500">Once you choose the content type, the matching category tree will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedContentType && (
|
||||
<div className="mt-5 space-y-5">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-200">{selectedContentType.name}</span>
|
||||
<span>contains {filteredCategoryTree.length} top-level {filteredCategoryTree.length === 1 ? 'category' : 'categories'}</span>
|
||||
</div>
|
||||
|
||||
{selectedRoot && !isCategoryChooserOpen && (
|
||||
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.08] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-purple-200/80">Selected category</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{selectedRoot.name}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{subCategories.length > 0
|
||||
? `Next step: choose one of the ${subCategories.length} subcategories below.`
|
||||
: 'This category is complete. No subcategory is required.'}
|
||||
</div>
|
||||
{selectedContentType && !isContentTypeChooserOpen && (
|
||||
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-emerald-400/30 bg-emerald-400/10">
|
||||
<img
|
||||
src={`/gfx/mascot_${getContentTypeVisualKey(selectedContentType)}.webp`}
|
||||
alt=""
|
||||
className="h-7 w-7 object-contain"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-200/70">Selected</div>
|
||||
<div className="mt-0.5 text-base font-semibold text-white">{selectedContentType.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsContentTypeChooserOpen(true)}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedContentType || isContentTypeChooserOpen) && (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{contentTypeOptions.map((ct) => {
|
||||
const typeValue = String(getContentTypeValue(ct))
|
||||
const isActive = typeValue === String(metadata.contentType || '')
|
||||
const visualKey = getContentTypeVisualKey(ct)
|
||||
const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0
|
||||
return (
|
||||
<button
|
||||
key={typeValue || ct.name}
|
||||
type="button"
|
||||
onClick={() => { setCategorySearch(''); setIsCategoryChooserOpen(true) }}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => {
|
||||
setIsContentTypeChooserOpen(false)
|
||||
setIsCategoryChooserOpen(true)
|
||||
onContentTypeChange(typeValue)
|
||||
}}
|
||||
className={[
|
||||
'group flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
isActive
|
||||
? 'border-emerald-400/40 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.18)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedRoot || isCategoryChooserOpen) && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
value={categorySearch}
|
||||
onChange={(e) => setCategorySearch(e.target.value)}
|
||||
placeholder="Search categories…"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30"
|
||||
/>
|
||||
</div>
|
||||
{sortedFilteredCategories.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">No categories match “{categorySearch}”</p>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sortedFilteredCategories.map((cat) => {
|
||||
const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
|
||||
const childCount = cat.children?.length || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCategoryChooserOpen(false)
|
||||
onRootCategoryChange(String(cat.id))
|
||||
}}
|
||||
className={[
|
||||
'rounded-2xl border px-4 py-4 text-left transition-all',
|
||||
isActive
|
||||
? 'border-purple-400/40 bg-purple-400/12 shadow-[0_0_0_1px_rgba(192,132,252,0.15)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories available` : 'Standalone category'}</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
|
||||
{isActive ? 'Selected' : 'Choose'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRoot && subCategories.length > 0 && (
|
||||
<div className="rounded-2xl border border-cyan-400/15 bg-cyan-400/[0.05] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h5 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Subcategories</h5>
|
||||
<p className="mt-1 text-sm text-slate-400">Refine <span className="text-white">{selectedRoot.name}</span> with one more level.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-1 text-[11px] text-cyan-200">{subCategories.length}</span>
|
||||
</div>
|
||||
|
||||
{!metadata.subCategoryId && requiresSubCategory && (
|
||||
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
|
||||
Subcategory still needs to be selected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSubCategory && !isSubCategoryChooserOpen && (
|
||||
<div className="mt-4 rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.09] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Selected subcategory</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{selectedSubCategory.name}</div>
|
||||
<div className="mt-1 text-sm text-slate-300">
|
||||
Final category path: <span className="text-white">{selectedRoot.name}</span> / <span className="text-cyan-100">{selectedSubCategory.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSubCategorySearch(''); setIsSubCategoryChooserOpen(true) }}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedSubCategory || isSubCategoryChooserOpen) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
value={subCategorySearch}
|
||||
onChange={(e) => setSubCategorySearch(e.target.value)}
|
||||
placeholder="Search subcategories…"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30"
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${isActive ? 'border-emerald-400/30 bg-emerald-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||
<img
|
||||
src={`/gfx/mascot_${visualKey}.webp`}
|
||||
alt=""
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
{sortedFilteredSubCategories.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">No subcategories match “{subCategorySearch}”</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-sm font-semibold ${isActive ? 'text-emerald-200' : 'text-white'}`}>{ct.name}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}</div>
|
||||
</div>
|
||||
<div className={`text-xs ${isActive ? 'text-emerald-300' : 'text-slate-500 group-hover:text-slate-300'}`}>
|
||||
{isActive ? 'Selected' : 'Open'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Category ── */}
|
||||
{selectedContentType && (
|
||||
<>
|
||||
<div className="my-5 border-t border-white/8" />
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</p>
|
||||
<span className="text-[11px] text-slate-600">{filteredCategoryTree.length} available</span>
|
||||
</div>
|
||||
|
||||
{selectedRoot && !isCategoryChooserOpen && (
|
||||
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.07] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-purple-200/70">Selected</div>
|
||||
<div className="mt-0.5 text-base font-semibold text-white">{selectedRoot.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{subCategories.length > 0
|
||||
? `${subCategories.length} subcategories available`
|
||||
: 'No subcategory required'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCategorySearch(''); setIsCategoryChooserOpen(true) }}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedRoot || isCategoryChooserOpen) && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
value={categorySearch}
|
||||
onChange={(e) => setCategorySearch(e.target.value)}
|
||||
placeholder="Search categories…"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30"
|
||||
/>
|
||||
</div>
|
||||
{sortedFilteredCategories.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">No categories match “{categorySearch}”</p>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sortedFilteredCategories.map((cat) => {
|
||||
const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
|
||||
const childCount = cat.children?.length || 0
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCategoryChooserOpen(false)
|
||||
onRootCategoryChange(String(cat.id))
|
||||
}}
|
||||
className={[
|
||||
'rounded-2xl border px-4 py-4 text-left transition-all',
|
||||
isActive
|
||||
? 'border-purple-400/40 bg-purple-400/12 shadow-[0_0_0_1px_rgba(192,132,252,0.15)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories` : 'Standalone'}</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
|
||||
{isActive ? 'Selected' : 'Choose'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadataErrors.category && <p className="mt-3 text-xs text-red-300">{metadataErrors.category}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Subcategory ── */}
|
||||
{selectedRoot && subCategories.length > 0 && (
|
||||
<>
|
||||
<div className="my-5 border-t border-white/8" />
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Subcategory</p>
|
||||
<span className="text-[11px] text-slate-600">{subCategories.length} available</span>
|
||||
</div>
|
||||
|
||||
{!metadata.subCategoryId && requiresSubCategory && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
|
||||
Subcategory still needs to be selected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSubCategory && !isSubCategoryChooserOpen && (
|
||||
<div className="rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.07] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-cyan-200/70">Selected</div>
|
||||
<div className="mt-0.5 text-base font-semibold text-white">{selectedSubCategory.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Path: <span className="text-slate-300">{selectedRoot.name}</span> / <span className="text-cyan-200">{selectedSubCategory.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSubCategorySearch(''); setIsSubCategoryChooserOpen(true) }}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!selectedSubCategory || isSubCategoryChooserOpen) && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
value={subCategorySearch}
|
||||
onChange={(e) => setSubCategorySearch(e.target.value)}
|
||||
placeholder="Search subcategories…"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30"
|
||||
/>
|
||||
</div>
|
||||
{sortedFilteredSubCategories.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">No subcategories match “{subCategorySearch}”</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sortedFilteredSubCategories.map((sub) => {
|
||||
const isActive = String(metadata.subCategoryId || '') === String(sub.id)
|
||||
return (
|
||||
@@ -424,46 +426,32 @@ export default function Step2Details({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={[
|
||||
'text-sm font-semibold transition-colors',
|
||||
isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white',
|
||||
].join(' ')}>
|
||||
<div className={['text-sm font-semibold transition-colors', isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white'].join(' ')}>
|
||||
{sub.name}
|
||||
</div>
|
||||
<div className={[
|
||||
'mt-1 text-xs',
|
||||
isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300',
|
||||
].join(' ')}>
|
||||
Subcategory option
|
||||
<div className={['mt-1 text-xs', isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300'].join(' ')}>
|
||||
Subcategory
|
||||
</div>
|
||||
</div>
|
||||
<span className={[
|
||||
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||
isActive
|
||||
? 'bg-cyan-300/15 text-cyan-100'
|
||||
: 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300',
|
||||
].join(' ')}>
|
||||
<span className={['shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium', isActive ? 'bg-cyan-300/15 text-cyan-100' : 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300'].join(' ')}>
|
||||
{isActive ? 'Selected' : 'Choose'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRoot && subCategories.length === 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
|
||||
<span className="font-medium text-white">{selectedRoot.name}</span> does not have subcategories. Selecting it is enough.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{metadataErrors.category && <p className="mt-4 text-xs text-red-300">{metadataErrors.category}</p>}
|
||||
{selectedRoot && subCategories.length === 0 && selectedRoot && (
|
||||
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-300">{selectedRoot.name}</span> has no subcategories — selecting it is enough.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Title, tags, description, rights */}
|
||||
@@ -472,6 +460,11 @@ export default function Step2Details({
|
||||
metadata={metadata}
|
||||
suggestedTags={suggestedTags}
|
||||
errors={metadataErrors}
|
||||
publishMode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={timezone}
|
||||
onPublishModeChange={onPublishModeChange}
|
||||
onScheduleAt={onScheduleAt}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onChangeTags={onChangeTags}
|
||||
onChangeDescription={onChangeDescription}
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function Step3Publish({
|
||||
scheduledAt = null,
|
||||
timezone = null,
|
||||
visibility = 'public',
|
||||
onVisibilityChange,
|
||||
// Category tree (for label lookup)
|
||||
allRootCategoryOptions = [],
|
||||
filteredCategoryTree = [],
|
||||
@@ -162,7 +163,45 @@ export default function Step3Publish({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publish summary: visibility + schedule */}
|
||||
{/* ── Visibility selector ────────────────────────────────────────── */}
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ 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' },
|
||||
].map((option) => {
|
||||
const active = visibility === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onVisibilityChange?.(option.value)}
|
||||
className={[
|
||||
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition',
|
||||
active
|
||||
? 'border-sky-300/30 bg-sky-400/10 text-white shadow-[0_0_0_1px_rgba(56,189,248,0.12)]'
|
||||
: '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/45">{option.hint}</div>
|
||||
</div>
|
||||
<span className={[
|
||||
'mt-0.5 inline-flex h-5 w-5 shrink-0 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/30',
|
||||
].join(' ')}>
|
||||
{active ? '✓' : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Publish summary: schedule info */}
|
||||
<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'}
|
||||
|
||||
Reference in New Issue
Block a user