Files
SkinbaseNova/resources/js/Pages/Collection/CollectionManage.jsx
2026-03-28 19:15:39 +01:00

3304 lines
180 KiB
JavaScript

import React, { useEffect, useMemo, useState } from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function slugify(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 140)
}
function isoToLocalInput(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
return local.toISOString().slice(0, 16)
}
function localInputToIso(value) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toISOString()
}
function formatDateTimeLabel(value) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleString()
}
function formatStateLabel(value) {
if (!value) return 'Unknown'
return String(value)
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function healthFlagMeta(flag) {
const tone = {
success: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
warning: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
danger: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
neutral: 'border-white/10 bg-white/[0.04] text-slate-200',
}
const registry = {
healthy: {
label: 'Healthy',
description: 'No active health blockers are currently recorded.',
tone: tone.success,
},
needs_metadata: {
label: 'Metadata is thin',
description: 'Tighten the title, summary, cover, or tagging so discovery surfaces have stronger context.',
tone: tone.warning,
},
stale: {
label: 'Stale collection',
description: 'This collection looks quiet. Refresh the lineup or update the presentation before promoting it again.',
tone: tone.warning,
},
low_content: {
label: 'Low content',
description: 'Add more artworks so the collection can support richer layouts and recommendations.',
tone: tone.warning,
},
broken_items: {
label: 'Broken items detected',
description: 'Some attached items are no longer safely displayable. Review attachments before featuring this set.',
tone: tone.danger,
},
weak_cover: {
label: 'Weak cover',
description: 'Choose a stronger cover artwork so the collection reads clearly on cards and hero rails.',
tone: tone.warning,
},
low_engagement: {
label: 'Low engagement',
description: 'This collection is live but underperforming. Consider adjusting ordering, title, or campaign context.',
tone: tone.warning,
},
attribution_incomplete: {
label: 'Attribution incomplete',
description: 'Cross-links or creator attribution need a pass before this collection is pushed harder.',
tone: tone.warning,
},
needs_review: {
label: 'Needs review',
description: 'Workflow or moderation state is still blocking this collection from safer public programming.',
tone: tone.danger,
},
duplicate_risk: {
label: 'Duplicate risk',
description: 'A similar collection may already exist. Use the merge review tools before spreading traffic across duplicates.',
tone: tone.warning,
},
merge_candidate: {
label: 'Merge candidate',
description: 'This collection already looks like a merge candidate. Confirm the canonical target in the review section below.',
tone: tone.warning,
},
}
return registry[flag] || {
label: formatStateLabel(flag),
description: 'Review this collection state before pushing it to wider surfaces.',
tone: tone.neutral,
}
}
function buildInviteExpiryOptions(defaultDays) {
const sanitizedDefault = Math.max(1, Number.parseInt(defaultDays, 10) || 7)
const values = [sanitizedDefault, 1, 3, 7, 14, 30]
return Array.from(new Set(values)).sort((left, right) => left - right)
}
function firstEntitySelection(options) {
const firstType = Object.keys(options || {})[0] || 'creator'
return {
type: firstType,
id: options?.[firstType]?.[0]?.id || '',
}
}
async function requestJson(url, { method = 'GET', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const message = payload?.message || 'Request failed.'
const error = new Error(message)
error.payload = payload
throw error
}
return payload
}
function defaultRuleValue(field) {
if (field === 'created_at') {
return { from: '', to: '' }
}
if (field === 'is_featured' || field === 'is_mature') {
return true
}
return ''
}
function operatorOptionsForField(field) {
if (field === 'created_at') {
return [{ value: 'between', label: 'Between' }]
}
if (field === 'is_featured' || field === 'is_mature') {
return [{ value: 'equals', label: 'Is' }]
}
return [
{ value: 'contains', label: 'Contains' },
{ value: 'equals', label: 'Equals' },
]
}
function createRule(field = 'tags') {
return {
field,
operator: operatorOptionsForField(field)[0]?.value || 'contains',
value: defaultRuleValue(field),
}
}
function normalizeRule(rule, fallbackField = 'tags') {
const field = rule?.field || fallbackField
const operators = operatorOptionsForField(field)
const operator = operators.some((item) => item.value === rule?.operator)
? rule.operator
: operators[0]?.value || 'contains'
if (field === 'created_at') {
return {
field,
operator,
value: {
from: rule?.value?.from || '',
to: rule?.value?.to || '',
},
}
}
if (field === 'is_featured' || field === 'is_mature') {
return {
field,
operator,
value: Boolean(rule?.value),
}
}
return {
field,
operator,
value: typeof rule?.value === 'string' ? rule.value : '',
}
}
function normalizeSmartRules(rawRules, mode = 'manual') {
if (rawRules && Array.isArray(rawRules.rules)) {
return {
match: rawRules.match === 'any' ? 'any' : 'all',
sort: rawRules.sort || 'newest',
rules: rawRules.rules.map((rule) => normalizeRule(rule)).filter(Boolean),
}
}
if (mode === 'smart') {
return {
match: 'all',
sort: 'newest',
rules: [createRule()],
}
}
return {
match: 'all',
sort: 'newest',
rules: [],
}
}
function normalizeLayoutModules(rawModules) {
if (!Array.isArray(rawModules)) return []
return rawModules.map((module) => ({
key: module?.key || '',
label: module?.label || module?.key || 'Module',
description: module?.description || '',
slot: module?.slot || 'main',
slots: Array.isArray(module?.slots) && module.slots.length ? module.slots : ['main'],
enabled: module?.enabled !== false,
locked: Boolean(module?.locked),
})).filter((module) => module.key)
}
function humanizeField(field, smartRuleOptions) {
const label = smartRuleOptions?.fields?.find((item) => item.value === field)?.label
return label || field
}
function getFieldOptions(field, smartRuleOptions) {
if (field === 'tags') return smartRuleOptions?.tag_options || []
if (field === 'category') return smartRuleOptions?.category_options || []
if (field === 'subcategory') return smartRuleOptions?.subcategory_options || []
if (field === 'medium') return smartRuleOptions?.medium_options || []
if (field === 'style') return smartRuleOptions?.style_options || []
if (field === 'color') return smartRuleOptions?.color_options || []
return []
}
function buildRuleSummary(rule, smartRuleOptions) {
if (!rule) return ''
if (rule.field === 'created_at') {
const from = rule.value?.from || 'any date'
const to = rule.value?.to || 'today'
return `Created between ${from} and ${to}`
}
if (rule.field === 'is_featured') {
return rule.value ? 'Featured artworks only' : 'Artworks not marked featured'
}
if (rule.field === 'is_mature') {
return rule.value ? 'Mature artworks only' : 'Artworks not marked mature'
}
const label = humanizeField(rule.field, smartRuleOptions)
const value = String(rule.value || '').trim() || 'Any value'
return `${label} ${rule.operator} ${value}`
}
function Field({ label, children, help }) {
return (
<label className="block space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</span>
{children}
{help ? <p className="text-xs text-slate-500">{help}</p> : null}
</label>
)
}
function StatCard({ icon, label, value, tone = 'default' }) {
const toneClass = tone === 'accent'
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
: 'border-white/10 bg-white/[0.04] text-white'
return (
<div className={`rounded-[24px] border px-4 py-4 ${toneClass}`}>
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
<i className={`fa-solid ${icon} text-[10px]`} />
{label}
</div>
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em]">{value}</div>
</div>
)
}
function ModeButton({ active, title, description, icon, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-[24px] border p-4 text-left transition ${active ? 'border-sky-300/30 bg-sky-400/10 text-sky-50 shadow-[0_18px_40px_rgba(14,165,233,0.12)]' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:bg-white/[0.06]'}`}
>
<div className="flex items-start gap-3">
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border ${active ? 'border-sky-300/25 bg-sky-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
<i className={`fa-solid ${icon}`} />
</div>
<div>
<div className="text-sm font-semibold uppercase tracking-[0.14em]">{title}</div>
<p className="mt-2 text-sm leading-relaxed text-slate-300">{description}</p>
</div>
</div>
</button>
)
}
function SmartPreviewArtwork({ artwork }) {
return (
<a
href={artwork.url}
className="group overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] transition hover:-translate-y-0.5 hover:bg-white/[0.06]"
>
<div className="aspect-[4/3] overflow-hidden bg-slate-950">
<img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
</div>
<div className="p-4">
<div className="truncate text-sm font-semibold text-white">{artwork.title}</div>
<div className="mt-1 text-xs text-slate-400">{[artwork.content_type, artwork.category].filter(Boolean).join(' • ') || 'Artwork'}</div>
</div>
</a>
)
}
function ArtworkPickerCard({ artwork, checked, onToggle, actionLabel = 'Select' }) {
return (
<button
type="button"
onClick={() => onToggle(artwork.id)}
className={`group w-full overflow-hidden rounded-[24px] border text-left transition ${checked ? 'border-sky-300/40 bg-sky-400/10' : 'border-white/10 bg-white/[0.04] hover:border-white/18 hover:bg-white/[0.06]'}`}
>
<div className="aspect-[4/3] overflow-hidden bg-slate-950">
<img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{artwork.title}</div>
<div className="mt-1 text-xs text-slate-400">{[artwork.content_type, artwork.category].filter(Boolean).join(' • ') || 'Artwork'}</div>
</div>
<span className={`inline-flex h-6 min-w-6 items-center justify-center rounded-full border px-2 text-[10px] font-semibold uppercase tracking-[0.14em] ${checked ? 'border-sky-300/40 bg-sky-400/15 text-sky-100' : 'border-white/12 text-slate-400'}`}>
{checked ? 'Added' : actionLabel}
</span>
</div>
</div>
</button>
)
}
function AttachedArtworkCard({ artwork, index, total, onMoveUp, onMoveDown, onRemove }) {
return (
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
<div className="aspect-[4/3] overflow-hidden bg-slate-950">
<img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover" loading="lazy" />
</div>
<div className="space-y-3 p-4">
<div>
<div className="truncate text-sm font-semibold text-white">{artwork.title}</div>
<div className="mt-1 text-xs text-slate-400">Position {index + 1}</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onMoveUp}
disabled={index === 0}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${index === 0 ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}
>
<i className="fa-solid fa-arrow-up fa-fw" />
Up
</button>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${index === total - 1 ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}
>
<i className="fa-solid fa-arrow-down fa-fw" />
Down
</button>
<button
type="button"
onClick={onRemove}
className="inline-flex items-center gap-2 rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 hover:bg-rose-400/15"
>
<i className="fa-solid fa-trash-can fa-fw" />
Remove
</button>
</div>
</div>
</div>
)
}
function MemberCard({ member, onRoleChange, onRemove, onAccept, onDecline, onTransfer }) {
const expiryLabel = formatDateTimeLabel(member?.expires_at)
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex items-center gap-3">
<img src={member?.user?.avatar_url} alt={member?.user?.name || member?.user?.username} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{member?.user?.name || member?.user?.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member?.role} {member?.status}</div>
{member?.status === 'pending' && expiryLabel ? <div className="mt-1 text-xs text-slate-500">Invite expires {expiryLabel}</div> : null}
{member?.is_expired ? <div className="mt-1 text-xs text-amber-300">Invite expired</div> : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{member?.can_revoke ? (
<select
value={member?.role}
onChange={(event) => onRoleChange?.(member, event.target.value)}
className="rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white"
>
<option value="editor">Editor</option>
<option value="contributor">Contributor</option>
<option value="viewer">Viewer</option>
</select>
) : null}
{member?.can_accept ? <button type="button" onClick={() => onAccept?.(member)} className="rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">Accept</button> : null}
{member?.can_decline ? <button type="button" onClick={() => onDecline?.(member)} className="rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">Decline</button> : null}
{member?.can_transfer ? <button type="button" onClick={() => onTransfer?.(member)} className="rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100">Make owner</button> : null}
{member?.can_revoke ? <button type="button" onClick={() => onRemove?.(member)} className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100">Remove</button> : null}
</div>
</div>
)
}
function SubmissionReviewCard({ submission, onApprove, onReject, onWithdraw }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex items-start gap-3">
{submission?.artwork?.thumb ? <img src={submission.artwork.thumb} alt={submission.artwork.title} className="h-16 w-16 rounded-2xl object-cover" /> : null}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{submission?.artwork?.title || 'Submission'}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{submission?.status} @{submission?.user?.username}</div>
{submission?.message ? <p className="mt-2 text-sm text-slate-300">{submission.message}</p> : null}
<div className="mt-3 flex flex-wrap gap-2">
{submission?.can_review ? <button type="button" onClick={() => onApprove?.(submission)} className="rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">Approve</button> : null}
{submission?.can_review ? <button type="button" onClick={() => onReject?.(submission)} className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100">Reject</button> : null}
{submission?.can_withdraw ? <button type="button" onClick={() => onWithdraw?.(submission)} className="rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">Withdraw</button> : null}
</div>
</div>
</div>
</div>
)
}
function AiSuggestionCard({ title, body, actionLabel, onAction, muted = false, children }) {
return (
<div className={`rounded-[24px] border p-4 ${muted ? 'border-dashed border-white/12 bg-white/[0.03]' : 'border-white/10 bg-white/[0.04]'}`}>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">{title}</div>
<div className="mt-2 text-sm leading-relaxed text-slate-200">{body}</div>
{children}
{actionLabel && onAction ? (
<button type="button" onClick={onAction} className="mt-4 rounded-xl border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15">
{actionLabel}
</button>
) : null}
</div>
)
}
function LayoutModuleCard({ module, index, total, onToggle, onSlotChange, onMoveUp, onMoveDown }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{module.label}</div>
<p className="mt-1 text-sm leading-relaxed text-slate-400">{module.description}</p>
</div>
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">
<input
type="checkbox"
checked={module.enabled}
disabled={module.locked}
onChange={(event) => onToggle(module.key, event.target.checked)}
/>
{module.locked ? 'Required' : (module.enabled ? 'Enabled' : 'Disabled')}
</label>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<Field label="Placement">
<select
value={module.slot}
onChange={(event) => onSlotChange(module.key, event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{module.slots.map((slot) => (
<option key={slot} value={slot}>{slot === 'full' ? 'Full width' : slot === 'main' ? 'Main column' : 'Sidebar'}</option>
))}
</select>
</Field>
<div className="flex flex-wrap items-end gap-2">
<button
type="button"
onClick={onMoveUp}
disabled={index === 0}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${index === 0 ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}
>
<i className="fa-solid fa-arrow-up fa-fw" />
Up
</button>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${index === total - 1 ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}
>
<i className="fa-solid fa-arrow-down fa-fw" />
Down
</button>
</div>
</div>
</div>
)
}
function StudioTabButton({ active, label, icon, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07] hover:text-white'}`}
>
<i className={`fa-solid ${icon} fa-fw text-[12px]`} />
{label}
</button>
)
}
function SmartRuleRow({
rule,
index,
smartRuleOptions,
onFieldChange,
onOperatorChange,
onValueChange,
onRemove,
}) {
const fieldOptions = smartRuleOptions?.fields || []
const operatorOptions = operatorOptionsForField(rule.field)
const valueOptions = getFieldOptions(rule.field, smartRuleOptions)
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Rule {index + 1}</div>
<button
type="button"
onClick={onRemove}
className="inline-flex items-center gap-2 rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/15"
>
<i className="fa-solid fa-trash-can fa-fw" />
Remove
</button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_180px_minmax(0,1.15fr)]">
<Field label="Field">
<select
value={rule.field}
onChange={(event) => onFieldChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{fieldOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
<Field label="Operator">
<select
value={rule.operator}
onChange={(event) => onOperatorChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{operatorOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
{rule.field === 'created_at' ? (
<Field label="Date Range">
<div className="grid gap-3 sm:grid-cols-2">
<input
type="date"
value={rule.value?.from || ''}
onChange={(event) => onValueChange({ ...(rule.value || {}), from: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
/>
<input
type="date"
value={rule.value?.to || ''}
onChange={(event) => onValueChange({ ...(rule.value || {}), to: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
/>
</div>
</Field>
) : rule.field === 'is_featured' || rule.field === 'is_mature' ? (
<Field label="Value">
<select
value={rule.value ? 'true' : 'false'}
onChange={(event) => onValueChange(event.target.value === 'true')}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{rule.field === 'is_featured' ? (
<>
<option value="true">Featured only</option>
<option value="false">Not featured</option>
</>
) : (
<>
<option value="true">Mature only</option>
<option value="false">Not mature</option>
</>
)}
</select>
</Field>
) : valueOptions.length ? (
<Field label="Value">
<select
value={rule.value}
onChange={(event) => onValueChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="">Select one</option>
{valueOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
) : (
<Field label="Value">
<input
type="text"
value={rule.value}
onChange={(event) => onValueChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Enter a value"
/>
</Field>
)}
</div>
<p className="mt-3 text-xs text-slate-400">{buildRuleSummary(rule, smartRuleOptions)}</p>
</div>
)
}
export default function CollectionManage() {
const { props } = usePage()
const {
mode,
collection,
layoutModules: initialLayoutModules,
attachedArtworks,
availableArtworks,
owner,
endpoints,
members: initialMembers,
submissions: initialSubmissions,
comments: initialComments,
duplicateCandidates: initialDuplicateCandidates,
canonicalTarget: initialCanonicalTarget,
linkedCollections: initialLinkedCollections,
linkedCollectionOptions: initialLinkedCollectionOptions,
entityLinks: initialEntityLinks,
entityLinkOptions: initialEntityLinkOptions,
smartPreview: initialSmartPreview,
smartRuleOptions,
initialMode,
featuredLimit,
viewer,
inviteExpiryDays,
} = props
const resolvedInitialMode = collection?.mode || initialMode || 'manual'
const [collectionState, setCollectionState] = useState(collection)
const [form, setForm] = useState({
title: collection?.title || '',
slug: collection?.slug || '',
subtitle: collection?.subtitle || '',
summary: collection?.summary || '',
description: collection?.description || '',
lifecycle_state: collection?.lifecycle_state || 'draft',
type: collection?.type || 'personal',
collaboration_mode: collection?.collaboration_mode || 'closed',
allow_submissions: Boolean(collection?.allow_submissions),
allow_comments: collection?.allow_comments !== false,
allow_saves: collection?.allow_saves !== false,
event_key: collection?.event_key || '',
event_label: collection?.event_label || '',
season_key: collection?.season_key || '',
banner_text: collection?.banner_text || '',
badge_label: collection?.badge_label || '',
spotlight_style: collection?.spotlight_style || 'default',
analytics_enabled: collection?.analytics_enabled !== false,
presentation_style: collection?.presentation_style || 'standard',
emphasis_mode: collection?.emphasis_mode || 'balanced',
theme_token: collection?.theme_token || 'default',
series_key: collection?.series_key || '',
series_title: collection?.series_title || '',
series_description: collection?.series_description || '',
series_order: collection?.series_order || '',
campaign_key: collection?.campaign_key || '',
campaign_label: collection?.campaign_label || '',
commercial_eligibility: Boolean(collection?.commercial_eligibility),
promotion_tier: collection?.promotion_tier || '',
sponsorship_label: collection?.sponsorship_label || '',
partner_label: collection?.partner_label || '',
monetization_ready_status: collection?.monetization_ready_status || '',
brand_safe_status: collection?.brand_safe_status || '',
editorial_notes: collection?.editorial_notes || '',
staff_commercial_notes: collection?.staff_commercial_notes || '',
published_at: isoToLocalInput(collection?.published_at),
unpublished_at: isoToLocalInput(collection?.unpublished_at),
archived_at: isoToLocalInput(collection?.archived_at),
expired_at: isoToLocalInput(collection?.expired_at),
editorial_owner_mode: collection?.owner?.mode || 'creator',
editorial_owner_username: collection?.owner?.username || '',
editorial_owner_label: collection?.type === 'editorial' && collection?.owner?.is_system ? (collection?.owner?.name || '') : '',
visibility: collection?.visibility || 'public',
mode: resolvedInitialMode,
sort_mode: collection?.sort_mode || (resolvedInitialMode === 'smart' ? 'newest' : 'manual'),
cover_artwork_id: collection?.cover_artwork_id || '',
})
const [slugTouched, setSlugTouched] = useState(Boolean(collection?.slug))
const [smartRules, setSmartRules] = useState(normalizeSmartRules(collection?.smart_rules_json, resolvedInitialMode))
const [smartPreview, setSmartPreview] = useState(initialSmartPreview || null)
const [layoutModules, setLayoutModules] = useState(normalizeLayoutModules(collection?.layout_modules || initialLayoutModules || []))
const [attached, setAttached] = useState(attachedArtworks || [])
const [available, setAvailable] = useState(availableArtworks || [])
const [members, setMembers] = useState(initialMembers || [])
const [submissions, setSubmissions] = useState(initialSubmissions || [])
const [comments] = useState(initialComments || [])
const [duplicateCandidates, setDuplicateCandidates] = useState(initialDuplicateCandidates || [])
const [canonicalTarget, setCanonicalTarget] = useState(initialCanonicalTarget || null)
const [linkedCollections, setLinkedCollections] = useState(initialLinkedCollections || [])
const [linkedCollectionOptions, setLinkedCollectionOptions] = useState(initialLinkedCollectionOptions || [])
const [selectedLinkedCollectionId, setSelectedLinkedCollectionId] = useState(initialLinkedCollectionOptions?.[0]?.id || '')
const [entityLinks, setEntityLinks] = useState(initialEntityLinks || [])
const [entityLinkOptions, setEntityLinkOptions] = useState(initialEntityLinkOptions || {})
const initialEntitySelection = useMemo(() => firstEntitySelection(initialEntityLinkOptions || {}), [initialEntityLinkOptions])
const [selectedEntityType, setSelectedEntityType] = useState(initialEntitySelection.type)
const [selectedEntityId, setSelectedEntityId] = useState(initialEntitySelection.id)
const [entityRelationship, setEntityRelationship] = useState('')
const [activeTab, setActiveTab] = useState(mode === 'edit' ? 'details' : 'details')
const [selectedIds, setSelectedIds] = useState([])
const [search, setSearch] = useState('')
const [inviteUsername, setInviteUsername] = useState('')
const [inviteRole, setInviteRole] = useState('contributor')
const [inviteExpiryMode, setInviteExpiryMode] = useState('default')
const [inviteCustomExpiry, setInviteCustomExpiry] = useState('')
const [saving, setSaving] = useState(false)
const [searching, setSearching] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [featureBusy, setFeatureBusy] = useState(false)
const [aiState, setAiState] = useState({ busy: '', title: null, summary: null, cover: null, grouping: null, relatedArtworks: null, tags: null, seoDescription: null, smartRulesExplanation: null, splitThemes: null, mergeIdea: null, qualityReview: null })
const [orderDirty, setOrderDirty] = useState(false)
const [errors, setErrors] = useState({})
const [notice, setNotice] = useState('')
useEffect(() => {
const nextMode = collection?.mode || initialMode || 'manual'
setCollectionState(collection)
setForm({
title: collection?.title || '',
slug: collection?.slug || '',
subtitle: collection?.subtitle || '',
summary: collection?.summary || '',
description: collection?.description || '',
lifecycle_state: collection?.lifecycle_state || 'draft',
type: collection?.type || 'personal',
collaboration_mode: collection?.collaboration_mode || 'closed',
allow_submissions: Boolean(collection?.allow_submissions),
allow_comments: collection?.allow_comments !== false,
allow_saves: collection?.allow_saves !== false,
event_key: collection?.event_key || '',
event_label: collection?.event_label || '',
season_key: collection?.season_key || '',
banner_text: collection?.banner_text || '',
badge_label: collection?.badge_label || '',
spotlight_style: collection?.spotlight_style || 'default',
analytics_enabled: collection?.analytics_enabled !== false,
presentation_style: collection?.presentation_style || 'standard',
emphasis_mode: collection?.emphasis_mode || 'balanced',
theme_token: collection?.theme_token || 'default',
series_key: collection?.series_key || '',
series_title: collection?.series_title || '',
series_description: collection?.series_description || '',
series_order: collection?.series_order || '',
campaign_key: collection?.campaign_key || '',
campaign_label: collection?.campaign_label || '',
commercial_eligibility: Boolean(collection?.commercial_eligibility),
promotion_tier: collection?.promotion_tier || '',
sponsorship_label: collection?.sponsorship_label || '',
partner_label: collection?.partner_label || '',
monetization_ready_status: collection?.monetization_ready_status || '',
brand_safe_status: collection?.brand_safe_status || '',
editorial_notes: collection?.editorial_notes || '',
staff_commercial_notes: collection?.staff_commercial_notes || '',
published_at: isoToLocalInput(collection?.published_at),
unpublished_at: isoToLocalInput(collection?.unpublished_at),
archived_at: isoToLocalInput(collection?.archived_at),
expired_at: isoToLocalInput(collection?.expired_at),
editorial_owner_mode: collection?.owner?.mode || 'creator',
editorial_owner_username: collection?.owner?.username || '',
editorial_owner_label: collection?.type === 'editorial' && collection?.owner?.is_system ? (collection?.owner?.name || '') : '',
visibility: collection?.visibility || 'public',
mode: nextMode,
sort_mode: collection?.sort_mode || (nextMode === 'smart' ? 'newest' : 'manual'),
cover_artwork_id: collection?.cover_artwork_id || '',
})
setSmartRules(normalizeSmartRules(collection?.smart_rules_json, nextMode))
setLayoutModules(normalizeLayoutModules(collection?.layout_modules || initialLayoutModules || []))
setSmartPreview(initialSmartPreview || null)
setAttached(attachedArtworks || [])
setAvailable(availableArtworks || [])
setMembers(initialMembers || [])
setSubmissions(initialSubmissions || [])
setDuplicateCandidates(initialDuplicateCandidates || [])
setCanonicalTarget(initialCanonicalTarget || null)
setLinkedCollections(initialLinkedCollections || [])
setLinkedCollectionOptions(initialLinkedCollectionOptions || [])
setSelectedLinkedCollectionId(initialLinkedCollectionOptions?.[0]?.id || '')
setEntityLinks(initialEntityLinks || [])
setEntityLinkOptions(initialEntityLinkOptions || {})
const nextEntitySelection = firstEntitySelection(initialEntityLinkOptions || {})
setSelectedEntityType(nextEntitySelection.type)
setSelectedEntityId(nextEntitySelection.id)
setEntityRelationship('')
setActiveTab('details')
setSelectedIds([])
setAiState({ busy: '', title: null, summary: null, cover: null, grouping: null, relatedArtworks: null, tags: null, seoDescription: null, smartRulesExplanation: null, splitThemes: null, mergeIdea: null, qualityReview: null })
setOrderDirty(false)
setErrors({})
setNotice('')
}, [collection?.id, attachedArtworks, availableArtworks, initialCanonicalTarget, initialDuplicateCandidates, initialEntityLinkOptions, initialEntityLinks, initialLayoutModules, initialLinkedCollectionOptions, initialLinkedCollections, initialMembers, initialSubmissions, initialMode, initialSmartPreview])
const attachedCoverOptions = useMemo(
() => attached.map((artwork) => ({ id: artwork.id, title: artwork.title })),
[attached]
)
const inviteExpiryOptions = useMemo(() => buildInviteExpiryOptions(inviteExpiryDays), [inviteExpiryDays])
const smartRuleCount = smartRules?.rules?.length || 0
const isSmartMode = form.mode === 'smart'
const canFeature = mode === 'edit' && form.visibility === 'public' && (collectionState?.feature_url || collectionState?.unfeature_url || endpoints?.feature)
const featuredCountLabel = collectionState?.is_featured ? 'Featured' : `Up to ${featuredLimit} featured collections`
const canModerate = mode === 'edit' && Boolean(viewer?.is_admin)
const tabs = [
{ id: 'details', label: 'Details', icon: 'fa-pen-ruler' },
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'members', label: 'Members', icon: 'fa-user-group' },
{ id: 'submissions', label: 'Submissions', icon: 'fa-inbox' },
{ id: 'settings', label: 'Settings', icon: 'fa-sliders' },
{ id: 'discussion', label: 'Discussion', icon: 'fa-comments' },
{ id: 'ai', label: 'AI Suggestions', icon: 'fa-wand-magic-sparkles' },
].concat(canModerate ? [{ id: 'moderation', label: 'Moderation', icon: 'fa-shield-halved' }] : [])
function applyCollectionPayload(nextCollection) {
if (!nextCollection) return
setCollectionState(nextCollection)
setForm((current) => ({
...current,
title: nextCollection.title ?? current.title,
slug: nextCollection.slug ?? current.slug,
subtitle: nextCollection.subtitle || '',
summary: nextCollection.summary || '',
description: nextCollection.description || '',
lifecycle_state: nextCollection.lifecycle_state || current.lifecycle_state,
type: nextCollection.type || current.type,
collaboration_mode: nextCollection.collaboration_mode || current.collaboration_mode,
allow_submissions: Boolean(nextCollection.allow_submissions),
allow_comments: nextCollection.allow_comments !== false,
allow_saves: nextCollection.allow_saves !== false,
event_key: nextCollection.event_key ?? '',
event_label: nextCollection.event_label ?? '',
season_key: nextCollection.season_key ?? '',
banner_text: nextCollection.banner_text ?? '',
badge_label: nextCollection.badge_label ?? '',
spotlight_style: nextCollection.spotlight_style || 'default',
analytics_enabled: nextCollection.analytics_enabled !== false,
presentation_style: nextCollection.presentation_style || current.presentation_style,
emphasis_mode: nextCollection.emphasis_mode || current.emphasis_mode,
theme_token: nextCollection.theme_token || current.theme_token,
series_key: nextCollection.series_key ?? '',
series_title: nextCollection.series_title ?? '',
series_description: nextCollection.series_description ?? '',
series_order: nextCollection.series_order ?? '',
campaign_key: nextCollection.campaign_key ?? '',
campaign_label: nextCollection.campaign_label ?? '',
commercial_eligibility: Boolean(nextCollection.commercial_eligibility),
promotion_tier: nextCollection.promotion_tier ?? '',
sponsorship_label: nextCollection.sponsorship_label ?? '',
partner_label: nextCollection.partner_label ?? '',
monetization_ready_status: nextCollection.monetization_ready_status ?? '',
brand_safe_status: nextCollection.brand_safe_status ?? '',
editorial_notes: nextCollection.editorial_notes ?? '',
staff_commercial_notes: nextCollection.staff_commercial_notes ?? '',
published_at: isoToLocalInput(nextCollection.published_at) || '',
unpublished_at: isoToLocalInput(nextCollection.unpublished_at) || '',
archived_at: isoToLocalInput(nextCollection.archived_at) || '',
expired_at: isoToLocalInput(nextCollection.expired_at) || '',
editorial_owner_mode: nextCollection?.owner?.mode || current.editorial_owner_mode,
editorial_owner_username: nextCollection?.owner?.username || current.editorial_owner_username,
editorial_owner_label: nextCollection?.type === 'editorial' && nextCollection?.owner?.is_system ? (nextCollection?.owner?.name || current.editorial_owner_label) : current.editorial_owner_label,
visibility: nextCollection.visibility || current.visibility,
mode: nextCollection.mode || current.mode,
sort_mode: nextCollection.sort_mode || current.sort_mode,
cover_artwork_id: nextCollection.cover_artwork_id || '',
}))
if (Array.isArray(nextCollection.layout_modules)) {
setLayoutModules(normalizeLayoutModules(nextCollection.layout_modules))
}
}
function updateForm(name, value) {
setForm((current) => {
const next = { ...current, [name]: value }
if (name === 'title' && !slugTouched) {
next.slug = slugify(value)
}
if (name === 'mode') {
next.sort_mode = value === 'smart'
? (smartRules?.sort || 'newest')
: (current.sort_mode === 'newest' || current.sort_mode === 'oldest' || current.sort_mode === 'popular' ? 'manual' : current.sort_mode || 'manual')
if (value === 'smart') {
next.cover_artwork_id = ''
}
}
return next
})
if (name === 'mode') {
setSmartRules((current) => {
if (value !== 'smart') {
return current
}
const normalized = normalizeSmartRules(current, 'smart')
if (normalized.rules.length > 0) {
return normalized
}
return {
...normalized,
rules: [createRule()],
}
})
}
}
function updateSmartRule(index, updater) {
setSmartRules((current) => ({
...current,
rules: current.rules.map((rule, ruleIndex) => (
ruleIndex === index ? updater(rule) : rule
)),
}))
}
function addRule() {
const defaultField = smartRuleOptions?.fields?.[0]?.value || 'tags'
setSmartRules((current) => ({
...current,
rules: [...current.rules, createRule(defaultField)],
}))
}
function removeRule(index) {
setSmartRules((current) => ({
...current,
rules: current.rules.filter((_, ruleIndex) => ruleIndex !== index),
}))
}
function buildPayload() {
return {
...form,
sort_mode: isSmartMode ? (smartRules.sort || form.sort_mode || 'newest') : form.sort_mode,
cover_artwork_id: isSmartMode ? null : (form.cover_artwork_id || null),
published_at: localInputToIso(form.published_at),
unpublished_at: localInputToIso(form.unpublished_at),
archived_at: localInputToIso(form.archived_at),
expired_at: localInputToIso(form.expired_at),
smart_rules_json: isSmartMode
? {
match: smartRules.match,
sort: smartRules.sort,
rules: smartRules.rules,
}
: null,
layout_modules_json: layoutModules.map((module) => ({
key: module.key,
enabled: module.enabled,
slot: module.slot,
})),
}
}
function updateLayoutModule(key, updates) {
setLayoutModules((current) => current.map((module) => (
module.key === key ? { ...module, ...updates } : module
)))
}
function moveLayoutModule(index, direction) {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= layoutModules.length) return
setLayoutModules((current) => {
const next = [...current]
const swap = next[index]
next[index] = next[nextIndex]
next[nextIndex] = swap
return next
})
}
async function handleSubmit(event) {
event.preventDefault()
setSaving(true)
setErrors({})
setNotice('')
try {
const payload = await requestJson(mode === 'create' ? endpoints.store : endpoints.update, {
method: mode === 'create' ? 'POST' : 'PATCH',
body: buildPayload(),
})
if (payload.redirect && mode === 'create') {
window.location.assign(payload.redirect)
return
}
if (payload.collection) {
applyCollectionPayload(payload.collection)
if (payload.collection.smart_rules_json) {
setSmartRules(normalizeSmartRules(payload.collection.smart_rules_json, payload.collection.mode))
}
}
if (payload.attachedArtworks) {
setAttached(payload.attachedArtworks)
}
if (payload.members) {
setMembers(payload.members)
}
if (payload.submissions) {
setSubmissions(payload.submissions)
}
setNotice(isSmartMode ? 'Collection saved and smart rules updated.' : 'Collection updated.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleSmartPreview() {
if (!endpoints?.smartPreview) return
setPreviewing(true)
setErrors({})
try {
const payload = await requestJson(endpoints.smartPreview, {
method: 'POST',
body: {
smart_rules_json: {
match: smartRules.match,
sort: smartRules.sort,
rules: smartRules.rules,
},
},
})
setSmartPreview(payload.preview || null)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setPreviewing(false)
}
}
async function handleSearch(event) {
event.preventDefault()
if (!endpoints?.available) return
setSearching(true)
try {
const url = `${endpoints.available}?search=${encodeURIComponent(search)}`
const payload = await requestJson(url)
setAvailable(payload?.data || [])
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSearching(false)
}
}
async function handleAiSuggestion(kind) {
const endpointMap = {
title: endpoints?.aiSuggestTitle,
summary: endpoints?.aiSuggestSummary,
cover: endpoints?.aiSuggestCover,
grouping: endpoints?.aiSuggestGrouping,
relatedArtworks: endpoints?.aiSuggestRelatedArtworks,
tags: endpoints?.aiSuggestTags,
seoDescription: endpoints?.aiSuggestSeoDescription,
smartRulesExplanation: endpoints?.aiExplainSmartRules,
splitThemes: endpoints?.aiSuggestSplitThemes,
mergeIdea: endpoints?.aiSuggestMergeIdea,
}
const url = endpointMap[kind]
if (!url) return
setAiState((current) => ({ ...current, busy: kind }))
try {
const payload = await requestJson(url, {
method: 'POST',
body: { draft: buildPayload() },
})
setAiState((current) => ({
...current,
busy: '',
[kind]: payload?.suggestion || null,
}))
} catch (error) {
setAiState((current) => ({ ...current, busy: '' }))
setErrors(error?.payload?.errors || { form: [error.message] })
}
}
async function handleAiQualityReview() {
if (!endpoints?.aiQualityReview) return
setAiState((current) => ({ ...current, busy: 'qualityReview' }))
try {
const payload = await requestJson(endpoints.aiQualityReview)
setAiState((current) => ({
...current,
busy: '',
qualityReview: payload?.review || null,
}))
} catch (error) {
setAiState((current) => ({ ...current, busy: '' }))
setErrors(error?.payload?.errors || { form: [error.message] })
}
}
function applyAiTitle() {
if (!aiState?.title?.title) return
updateForm('title', aiState.title.title)
}
function applyAiSummary() {
if (!aiState?.summary?.summary) return
updateForm('summary', aiState.summary.summary)
}
function applyAiCover() {
const artworkId = aiState?.cover?.artwork?.id
if (!artworkId || isSmartMode) return
updateForm('cover_artwork_id', artworkId)
}
function applyAiRelatedArtworks() {
const artworkIds = Array.isArray(aiState?.relatedArtworks?.artworks)
? aiState.relatedArtworks.artworks.map((artwork) => artwork.id)
: []
if (!artworkIds.length) return
setSelectedIds((current) => Array.from(new Set([...current, ...artworkIds])))
}
function toggleSelected(artworkId) {
setSelectedIds((current) => (
current.includes(artworkId)
? current.filter((id) => id !== artworkId)
: [...current, artworkId]
))
}
async function handleAttachSelected() {
if (!selectedIds.length || !endpoints?.attach) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.attach, {
method: 'POST',
body: { artwork_ids: selectedIds },
})
setCollectionState(payload.collection)
setAttached(payload.attachedArtworks || [])
setAvailable(payload.availableArtworks || [])
setSelectedIds([])
setNotice('Artworks added to collection.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleRemoveArtwork(artwork) {
if (!artwork?.remove_url) return
if (!window.confirm(`Remove "${artwork.title}" from this collection?`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(artwork.remove_url, { method: 'DELETE' })
setCollectionState(payload.collection)
setAttached(payload.attachedArtworks || [])
setAvailable(payload.availableArtworks || [])
setForm((current) => ({
...current,
cover_artwork_id: payload.collection?.cover_artwork_id || '',
}))
setNotice('Artwork removed from collection.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
function moveArtwork(index, direction) {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= attached.length) return
setAttached((current) => {
const next = [...current]
const temp = next[index]
next[index] = next[nextIndex]
next[nextIndex] = temp
return next
})
setOrderDirty(true)
}
async function saveArtworkOrder() {
if (!orderDirty || !endpoints?.reorder) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.reorder, {
method: 'POST',
body: { ordered_artwork_ids: attached.map((artwork) => artwork.id) },
})
setCollectionState(payload.collection)
setAttached(payload.attachedArtworks || [])
setOrderDirty(false)
setNotice('Artwork order saved.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleToggleFeature() {
const isFeatured = Boolean(collectionState?.is_featured)
const url = isFeatured ? endpoints?.unfeature : endpoints?.feature
if (!url) return
setFeatureBusy(true)
setErrors({})
try {
const payload = await requestJson(url, {
method: isFeatured ? 'DELETE' : 'POST',
})
if (payload.collection) {
applyCollectionPayload(payload.collection)
}
setNotice(isFeatured ? 'Collection removed from featured placement.' : 'Collection featured on your profile.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setFeatureBusy(false)
}
}
async function syncLinkedCollections(nextIds, successMessage) {
if (!endpoints?.syncLinkedCollections) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.syncLinkedCollections, {
method: 'POST',
body: {
related_collection_ids: nextIds,
},
})
if (payload?.collection) {
applyCollectionPayload(payload.collection)
}
const nextLinkedCollections = payload?.linkedCollections || []
const nextOptions = payload?.linkedCollectionOptions || []
setLinkedCollections(nextLinkedCollections)
setLinkedCollectionOptions(nextOptions)
setSelectedLinkedCollectionId((current) => {
if (current && nextOptions.some((option) => String(option.id) === String(current))) {
return current
}
return nextOptions[0]?.id || ''
})
setNotice(successMessage)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleAddLinkedCollection() {
if (!selectedLinkedCollectionId) return
const nextIds = Array.from(new Set([
...linkedCollections.map((item) => item.id),
Number(selectedLinkedCollectionId),
]))
await syncLinkedCollections(nextIds, 'Linked collections updated.')
}
async function handleRemoveLinkedCollection(collectionId) {
const nextIds = linkedCollections
.map((item) => item.id)
.filter((id) => Number(id) !== Number(collectionId))
await syncLinkedCollections(nextIds, 'Linked collections updated.')
}
async function syncEntityLinks(nextLinks, successMessage) {
if (!endpoints?.syncEntityLinks) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.syncEntityLinks, {
method: 'POST',
body: {
entity_links: nextLinks,
},
})
if (payload?.collection) {
applyCollectionPayload(payload.collection)
}
const nextEntityLinks = payload?.entityLinks || []
const nextOptions = payload?.entityLinkOptions || {}
setEntityLinks(nextEntityLinks)
setEntityLinkOptions(nextOptions)
setSelectedEntityId((current) => {
const optionsForType = Array.isArray(nextOptions[selectedEntityType]) ? nextOptions[selectedEntityType] : []
if (current && optionsForType.some((option) => String(option.id) === String(current))) {
return current
}
return optionsForType[0]?.id || ''
})
setEntityRelationship('')
setNotice(successMessage)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleAddEntityLink() {
if (!selectedEntityType || !selectedEntityId) return
const nextLinks = [
...entityLinks.map((item) => ({
linked_type: item.linked_type,
linked_id: item.linked_id,
relationship_type: item.relationship_type || null,
})),
{
linked_type: selectedEntityType,
linked_id: Number(selectedEntityId),
relationship_type: entityRelationship.trim() || null,
},
]
await syncEntityLinks(nextLinks, 'Entity links updated.')
}
async function handleRemoveEntityLink(linkId) {
const nextLinks = entityLinks
.filter((item) => Number(item.id) !== Number(linkId))
.map((item) => ({
linked_type: item.linked_type,
linked_id: item.linked_id,
relationship_type: item.relationship_type || null,
}))
await syncEntityLinks(nextLinks, 'Entity links updated.')
}
function applyMergeReviewPayload(payload, successMessage) {
if (payload?.collection) {
applyCollectionPayload(payload.collection)
}
if (payload?.source) {
applyCollectionPayload(payload.source)
}
if (Object.prototype.hasOwnProperty.call(payload || {}, 'duplicate_candidates')) {
setDuplicateCandidates(Array.isArray(payload?.duplicate_candidates) ? payload.duplicate_candidates : [])
}
if (Object.prototype.hasOwnProperty.call(payload || {}, 'canonical_target')) {
setCanonicalTarget(payload?.canonical_target || null)
}
if (successMessage) {
setNotice(successMessage)
}
}
async function handleCanonicalizeCandidate(candidate) {
const targetId = candidate?.collection?.id
if (!targetId || !endpoints?.canonicalize) return
if (!window.confirm(`Designate "${candidate.collection.title}" as the canonical target for this collection?`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.canonicalize, {
method: 'POST',
body: { target_collection_id: targetId },
})
applyMergeReviewPayload(payload, 'Canonical target updated.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleMergeCandidate(candidate) {
const targetId = candidate?.collection?.id
if (!targetId || !endpoints?.merge) return
if (!window.confirm(`Merge this collection into "${candidate.collection.title}"? This archives the current collection and moves artworks into the target.`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.merge, {
method: 'POST',
body: { target_collection_id: targetId },
})
applyMergeReviewPayload(payload, 'Collection merged into canonical target.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleRejectDuplicateCandidate(candidate) {
const targetId = candidate?.collection?.id
if (!targetId || !endpoints?.rejectDuplicate) return
if (!window.confirm(`Mark "${candidate.collection.title}" as not a duplicate?`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.rejectDuplicate, {
method: 'POST',
body: { target_collection_id: targetId },
})
applyMergeReviewPayload(payload, 'Duplicate candidate dismissed.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleDeleteCollection() {
if (!endpoints?.delete) return
if (!window.confirm('Delete this collection? Artworks will remain untouched.')) return
setSaving(true)
try {
const payload = await requestJson(endpoints.delete, { method: 'DELETE' })
window.location.assign(payload.redirect)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
setSaving(false)
}
}
async function handleInviteMember(event) {
event.preventDefault()
if (!inviteUsername || !endpoints?.inviteMember) return
const body = { username: inviteUsername, role: inviteRole }
if (inviteExpiryMode === 'custom') {
const customExpiryIso = localInputToIso(inviteCustomExpiry)
if (!customExpiryIso) {
setErrors({ expires_at: ['Choose a valid invite expiry date and time.'] })
return
}
body.expires_at = customExpiryIso
} else if (inviteExpiryMode !== 'default') {
body.expires_in_days = Number.parseInt(inviteExpiryMode, 10)
}
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.inviteMember, {
method: 'POST',
body,
})
setMembers(payload?.members || [])
setInviteUsername('')
setInviteExpiryMode('default')
setInviteCustomExpiry('')
setNotice('Collaborator invited.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleMemberRoleChange(member, role) {
const url = endpoints?.memberUpdatePattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, {
method: 'PATCH',
body: { role },
})
setMembers(payload?.members || [])
}
async function handleRemoveMember(member) {
const url = endpoints?.memberDeletePattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'DELETE' })
setMembers(payload?.members || [])
}
async function handleTransferMember(member) {
const url = endpoints?.memberTransferPattern?.replace('__MEMBER__', member.id)
if (!url) return
if (!window.confirm(`Transfer collection ownership to @${member?.user?.username}? You will keep editor access.`)) return
const payload = await requestJson(url, { method: 'POST' })
applyCollectionPayload(payload?.collection)
setMembers(payload?.members || [])
setNotice(`Ownership transferred to @${member?.user?.username}.`)
}
async function handleAcceptMember(member) {
const url = endpoints?.acceptMemberPattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'POST' })
setMembers(payload?.members || [])
}
async function handleDeclineMember(member) {
const url = endpoints?.declineMemberPattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'POST' })
setMembers(payload?.members || [])
}
async function handleSubmissionAction(submission, action) {
const url = action === 'approve'
? endpoints?.submissionApprovePattern?.replace('__SUBMISSION__', submission.id)
: action === 'reject'
? endpoints?.submissionRejectPattern?.replace('__SUBMISSION__', submission.id)
: endpoints?.submissionDeletePattern?.replace('__SUBMISSION__', submission.id)
if (!url) return
const payload = await requestJson(url, { method: action === 'withdraw' ? 'DELETE' : 'POST' })
setSubmissions(payload?.submissions || [])
}
async function handleModerationStatusChange(value) {
if (!endpoints?.adminModerationUpdate) return
const payload = await requestJson(endpoints.adminModerationUpdate, {
method: 'PATCH',
body: { moderation_status: value },
})
applyCollectionPayload(payload?.collection)
setNotice(`Moderation state updated to ${value.replace('_', ' ')}.`)
}
async function handleModerationToggle(key, value) {
if (!endpoints?.adminInteractionsUpdate) return
const payload = await requestJson(endpoints.adminInteractionsUpdate, {
method: 'PATCH',
body: { [key]: value },
})
applyCollectionPayload(payload?.collection)
setNotice('Collection interaction settings updated.')
}
async function handleAdminUnfeature() {
if (!endpoints?.adminUnfeature) return
const payload = await requestJson(endpoints.adminUnfeature, {
method: 'POST',
})
applyCollectionPayload(payload?.collection)
setNotice('Collection removed from featured placement by moderation action.')
}
async function handleAdminRemoveMember(member) {
const url = endpoints?.adminMemberRemovePattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'DELETE' })
applyCollectionPayload(payload?.collection)
setMembers(payload?.members || [])
setNotice('Collaborator removed by moderation action.')
}
return (
<>
<Head>
<title>{mode === 'create' ? 'Create Collection — Skinbase Nova' : `${collectionState?.title || 'Collection'} — Manage Collection`}</title>
<meta name="robots" content="noindex,nofollow" />
</Head>
<div className="relative min-h-screen overflow-hidden pb-16">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95"
style={{
background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.18), transparent 28%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)',
}}
/>
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
<a href={endpoints?.profileCollections || '#'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
Back to profile collections
</a>
{collectionState?.public_url ? (
<a href={collectionState.public_url} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-eye fa-fw text-[11px]" />
View public page
</a>
) : null}
{mode === 'edit' && endpoints?.dashboard ? (
<a href={endpoints.dashboard} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-chart-pie fa-fw text-[11px]" />
Dashboard
</a>
) : null}
{mode === 'edit' && endpoints?.analytics ? (
<a href={endpoints.analytics} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-chart-column fa-fw text-[11px]" />
Analytics
</a>
) : null}
{mode === 'edit' && endpoints?.history ? (
<a href={endpoints.history} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-timeline fa-fw text-[11px]" />
History
</a>
) : null}
{mode === 'edit' && endpoints?.staffSurfaces ? (
<a href={endpoints.staffSurfaces} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-thumbtack fa-fw text-[11px]" />
Staff surfaces
</a>
) : null}
{mode === 'edit' && endpoints?.staffProgramming ? (
<a href={endpoints.staffProgramming} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-sliders fa-fw text-[11px]" />
Programming studio
</a>
) : null}
<a href="/collections/featured" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-compass fa-fw text-[11px]" />
Featured collections
</a>
</div>
{mode === 'edit' ? (
<div className="mt-5 flex flex-wrap gap-2">
{tabs.map((tab) => (
<StudioTabButton
key={tab.id}
active={activeTab === tab.id}
label={tab.label}
icon={tab.icon}
onClick={() => setActiveTab(tab.id)}
/>
))}
</div>
) : null}
{mode !== 'edit' || activeTab === 'details' ? (
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.12fr)_420px]">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collection Studio</p>
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white md:text-4xl">
{mode === 'create' ? 'Create a v4 collection' : collectionState?.title || 'Manage collection'}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
Collections now carry lifecycle, presentation, campaign, and series metadata alongside the artwork curation itself. Use manual mode for exact storytelling or smart rules for creator-first automation.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{mode === 'edit' ? <CollectionVisibilityBadge visibility={form.visibility} /> : null}
{collectionState?.is_featured ? (
<span className="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Featured
</span>
) : null}
{isSmartMode ? (
<span className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
Smart Collection
</span>
) : null}
</div>
</div>
{notice ? (
<div className="mt-5 rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">
{notice}
</div>
) : null}
{Object.keys(errors).length ? (
<div className="mt-5 rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">
{Object.entries(errors).map(([key, messages]) => (
<div key={key}>{Array.isArray(messages) ? messages[0] : messages}</div>
))}
</div>
) : null}
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<ModeButton
active={!isSmartMode}
title="Manual"
description="Curate artworks yourself, control the exact order, and choose a specific cover from attached pieces."
icon="fa-hand-sparkles"
onClick={() => updateForm('mode', 'manual')}
/>
<ModeButton
active={isSmartMode}
title="Smart"
description="Build a rule-based collection that automatically pulls matching artworks from your own published gallery."
icon="fa-wand-magic-sparkles"
onClick={() => updateForm('mode', 'smart')}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Title">
<input
type="text"
value={form.title}
onChange={(event) => updateForm('title', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Dark Fantasy Series"
maxLength={120}
/>
</Field>
<Field label="Slug" help="Used in the collection URL.">
<input
type="text"
value={form.slug}
onChange={(event) => {
setSlugTouched(true)
updateForm('slug', slugify(event.target.value))
}}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="dark-fantasy-series"
maxLength={140}
/>
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Subtitle" help="Optional short line that sits under the title.">
<input
type="text"
value={form.subtitle}
onChange={(event) => updateForm('subtitle', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="A moody archive of midnight environments"
maxLength={160}
/>
</Field>
<Field label="Summary" help="Optional short summary for cards and meta previews.">
<input
type="text"
value={form.summary}
onChange={(event) => updateForm('summary', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Best performing sci-fi wallpapers from the last year"
maxLength={320}
/>
</Field>
</div>
<Field label="Description">
<textarea
value={form.description}
onChange={(event) => updateForm('description', event.target.value)}
className="min-h-[128px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Describe the mood, focus, or story behind this showcase."
maxLength={1000}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Collection Type">
<select value={form.type} onChange={(event) => updateForm('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="personal">Personal</option>
<option value="community">Community</option>
<option value="editorial">Editorial</option>
</select>
</Field>
<Field label="Collaboration">
<select value={form.collaboration_mode} onChange={(event) => updateForm('collaboration_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="closed">Closed</option>
<option value="invite_only">Invite Only</option>
<option value="open">Open Submissions</option>
</select>
</Field>
<Field label="Event Key" help="Internal campaign identifier for discovery and promotion logic.">
<input type="text" value={form.event_key} onChange={(event) => updateForm('event_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Event Label" help="Optional campaign or seasonal label.">
<input type="text" value={form.event_label} onChange={(event) => updateForm('event_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Season Key" help="Optional seasonal key used for grouped landing surfaces.">
<input type="text" value={form.season_key} onChange={(event) => updateForm('season_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Badge Label" help="Short public badge for cards and headers.">
<input type="text" value={form.badge_label} onChange={(event) => updateForm('badge_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Spotlight Style" help="Choose how the public campaign banner should be framed.">
<select value={form.spotlight_style} onChange={(event) => updateForm('spotlight_style', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default</option>
<option value="editorial">Editorial</option>
<option value="seasonal">Seasonal</option>
<option value="challenge">Challenge</option>
<option value="community">Community</option>
</select>
</Field>
<Field label="Banner Text" help="Optional short line displayed as the collection spotlight banner.">
<input type="text" value={form.banner_text} onChange={(event) => updateForm('banner_text', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={200} />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Publish At" help="Leave empty to publish immediately. Future times keep the collection off public surfaces until it goes live.">
<input type="datetime-local" value={form.published_at} onChange={(event) => updateForm('published_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Unpublish At" help="Optional automatic sunset time for seasonal or editorial collections.">
<input type="datetime-local" value={form.unpublished_at} onChange={(event) => updateForm('unpublished_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Lifecycle State" help="Controls whether the collection should be treated as draft, scheduled, published, or retired.">
<select value={form.lifecycle_state} onChange={(event) => updateForm('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="published">Published</option>
<option value="featured">Featured</option>
<option value="archived">Archived</option>
<option value="expired">Expired</option>
</select>
</Field>
<Field label="Presentation Style">
<select value={form.presentation_style} onChange={(event) => updateForm('presentation_style', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="standard">Standard</option>
<option value="editorial_grid">Editorial Grid</option>
<option value="hero_grid">Hero Grid</option>
<option value="masonry">Masonry</option>
</select>
</Field>
<Field label="Emphasis Mode">
<select value={form.emphasis_mode} onChange={(event) => updateForm('emphasis_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="cover_heavy">Cover Heavy</option>
<option value="balanced">Balanced</option>
<option value="artwork_first">Artwork First</option>
</select>
</Field>
<Field label="Theme Token">
<select value={form.theme_token} onChange={(event) => updateForm('theme_token', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default</option>
<option value="subtle-blue">Subtle Blue</option>
<option value="violet">Violet</option>
<option value="amber">Amber</option>
</select>
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Series Key" help="Use the same key across linked collections in a series.">
<input type="text" value={form.series_key} onChange={(event) => updateForm('series_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Series Title" help="Optional public heading for the whole series.">
<input type="text" value={form.series_title} onChange={(event) => updateForm('series_title', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={160} />
</Field>
<Field label="Series Order" help="Sequence within the series for public next and previous navigation.">
<input type="number" min="1" max="9999" value={form.series_order} onChange={(event) => updateForm('series_order', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Campaign Key" help="Operational campaign identifier for discovery, placements, and recommendations.">
<input type="text" value={form.campaign_key} onChange={(event) => updateForm('campaign_key', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={80} />
</Field>
<Field label="Campaign Label" help="Public-facing campaign or promotion label.">
<input type="text" value={form.campaign_label} onChange={(event) => updateForm('campaign_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
</div>
<Field label="Series Description" help="Optional public intro shown on series landing pages and collection series callouts.">
<textarea
value={form.series_description}
onChange={(event) => updateForm('series_description', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
maxLength={400}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Archive At" help="Optional timestamp for moving the collection into long-term archive workflows.">
<input type="datetime-local" value={form.archived_at} onChange={(event) => updateForm('archived_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Expire At" help="Optional hard expiry for promotional or seasonal collections.">
<input type="datetime-local" value={form.expired_at} onChange={(event) => updateForm('expired_at', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
</Field>
<Field label="Promotion Tier">
<input type="text" value={form.promotion_tier} onChange={(event) => updateForm('promotion_tier', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<Field label="Monetization Status">
<input type="text" value={form.monetization_ready_status} onChange={(event) => updateForm('monetization_ready_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Field label="Sponsorship Label">
<input type="text" value={form.sponsorship_label} onChange={(event) => updateForm('sponsorship_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Partner Label">
<input type="text" value={form.partner_label} onChange={(event) => updateForm('partner_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field>
<Field label="Brand Safe Status">
<input type="text" value={form.brand_safe_status} onChange={(event) => updateForm('brand_safe_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} />
Analytics enabled
</label>
</div>
<Field label="Editorial Notes" help="Internal editorial context for campaign planning, curation rationale, and staff handoff.">
<textarea
value={form.editorial_notes}
onChange={(event) => updateForm('editorial_notes', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
maxLength={2000}
/>
</Field>
{canModerate ? (
<Field label="Staff Commercial Notes" help="Internal admin-only notes for sponsorship readiness, partner handling, and commercial review.">
<textarea
value={form.staff_commercial_notes}
onChange={(event) => updateForm('staff_commercial_notes', event.target.value)}
className="min-h-[96px] w-full rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-white outline-none transition focus:border-amber-300/35 focus:bg-amber-400/15"
maxLength={2000}
/>
</Field>
) : null}
{form.type === 'editorial' ? (
<div className="grid gap-5 md:grid-cols-3">
<Field label="Editorial Owner Mode" help="Choose whether this editorial lives under the current curator, another staff account, or the system editorial identity.">
<select value={form.editorial_owner_mode} onChange={(event) => updateForm('editorial_owner_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="creator">Current curator</option>
<option value="staff_account">Staff account</option>
<option value="system">System editorial identity</option>
</select>
</Field>
{form.editorial_owner_mode === 'staff_account' ? (
<Field label="Staff Account Username" help="Must be an admin or moderator username.">
<input
type="text"
value={form.editorial_owner_username}
onChange={(event) => updateForm('editorial_owner_username', event.target.value.trimStart())}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="skinbase-editorial"
maxLength={60}
/>
</Field>
) : null}
{form.editorial_owner_mode === 'system' ? (
<Field label="System Owner Label" help="Public-facing label for system-owned editorials.">
<input
type="text"
value={form.editorial_owner_label}
onChange={(event) => updateForm('editorial_owner_label', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
placeholder="Skinbase Editorial"
maxLength={120}
/>
</Field>
) : null}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-3">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} />
Allow submissions
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} />
Allow comments
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} />
Allow saves
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.commercial_eligibility} onChange={(event) => updateForm('commercial_eligibility', event.target.checked)} />
Commercially eligible
</label>
</div>
<div className="grid gap-5 md:grid-cols-3">
<Field label="Visibility">
<select value={form.visibility} onChange={(event) => updateForm('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Private</option>
</select>
</Field>
{!isSmartMode ? (
<Field label="Sort Mode" help="Manual keeps the display order under your control.">
<select value={form.sort_mode} onChange={(event) => updateForm('sort_mode', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="manual">Manual</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Popular</option>
</select>
</Field>
) : (
<Field label="Match Mode" help="All means every rule must match. Any is broader.">
<select
value={smartRules.match}
onChange={(event) => setSmartRules((current) => ({ ...current, match: event.target.value }))}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="all">All rules</option>
<option value="any">Any rule</option>
</select>
</Field>
)}
{!isSmartMode ? (
<Field label="Cover Artwork" help={attachedCoverOptions.length ? 'Choose a cover from artworks already attached to this collection.' : 'Attach artworks first to pick a manual cover.'}>
<select
value={form.cover_artwork_id}
onChange={(event) => updateForm('cover_artwork_id', event.target.value)}
disabled={!attachedCoverOptions.length}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:text-slate-500"
>
<option value="">Automatic cover</option>
{attachedCoverOptions.map((artwork) => (
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
))}
</select>
</Field>
) : (
<Field label="Smart Sort" help="How matching artworks should be ordered.">
<select
value={smartRules.sort}
onChange={(event) => {
setSmartRules((current) => ({ ...current, sort: event.target.value }))
updateForm('sort_mode', event.target.value)
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{(smartRuleOptions?.sort_options || []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk'} fa-fw`} />
{mode === 'create' ? 'Create Collection' : 'Save Changes'}
</button>
{isSmartMode ? (
<button
type="button"
onClick={handleSmartPreview}
disabled={previewing}
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${previewing ? 'fa-circle-notch fa-spin' : 'fa-binoculars'} fa-fw`} />
Preview Matches
</button>
) : null}
{mode === 'edit' ? (
<button
type="button"
onClick={handleDeleteCollection}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-rose-400/20 bg-rose-400/10 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-trash-can fa-fw" />
Delete Collection
</button>
) : null}
</div>
</form>
</section>
<aside className="space-y-5">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Owner</p>
<div className="mt-4 flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<img src={owner?.avatar_url} alt={owner?.name || owner?.username} className="h-16 w-16 rounded-[20px] object-cover ring-1 ring-white/10" />
<div>
<div className="text-lg font-semibold text-white">{owner?.name || owner?.username}</div>
<div className="text-sm text-slate-400">@{owner?.username}</div>
</div>
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Presentation</p>
<div className="mt-4 space-y-3 text-sm text-slate-300">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Lifecycle</span>
<span className="font-semibold capitalize text-white">{String(form.lifecycle_state || 'draft').replace('_', ' ')}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Mode</span>
<span className="font-semibold text-white">{isSmartMode ? 'Smart' : 'Manual'}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Visibility</span>
<CollectionVisibilityBadge visibility={form.visibility} />
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Feature status</span>
<span className="font-semibold text-white">{featuredCountLabel}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Style</span>
<span className="font-semibold text-white">{String(form.presentation_style || 'standard').replace(/_/g, ' ')}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Analytics</span>
<span className="font-semibold text-white">{form.analytics_enabled ? 'Enabled' : 'Disabled'}</span>
</div>
{canFeature ? (
<button
type="button"
onClick={handleToggleFeature}
disabled={featureBusy}
className={`inline-flex w-full items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${collectionState?.is_featured ? 'border-amber-300/25 bg-amber-300/10 text-amber-100 hover:bg-amber-300/15' : 'border-sky-300/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15'} disabled:cursor-not-allowed disabled:opacity-60`}
>
<i className={`fa-solid ${featureBusy ? 'fa-circle-notch fa-spin' : (collectionState?.is_featured ? 'fa-star' : 'fa-sparkles')} fa-fw`} />
{collectionState?.is_featured ? 'Remove from Featured' : 'Feature this Collection'}
</button>
) : null}
{mode === 'edit' && form.visibility !== 'public' ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-xs leading-relaxed text-slate-400">
Only public collections can be featured, liked, or followed publicly.
</div>
) : null}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Live Stats</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<StatCard icon="fa-images" label="Artworks" value={(collectionState?.artworks_count ?? attached.length ?? 0).toLocaleString()} tone="accent" />
<StatCard icon="fa-eye" label="Views" value={(collectionState?.views_count ?? 0).toLocaleString()} />
<StatCard icon="fa-heart" label="Likes" value={(collectionState?.likes_count ?? 0).toLocaleString()} />
<StatCard icon="fa-bell" label="Followers" value={(collectionState?.followers_count ?? 0).toLocaleString()} />
</div>
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-xs leading-relaxed text-slate-400">
Shares: {(collectionState?.shares_count ?? 0).toLocaleString()} {collectionState?.last_activity_at ? `• Active ${new Date(collectionState.last_activity_at).toLocaleDateString()}` : ''}
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<StatCard icon="fa-gauge-high" label="Quality" value={collectionState?.quality_score != null ? Number(collectionState.quality_score).toFixed(1) : 'Pending'} />
<StatCard icon="fa-ranking-star" label="Ranking" value={collectionState?.ranking_score != null ? Number(collectionState.ranking_score).toFixed(1) : 'Pending'} />
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Series & Campaign</p>
<div className="mt-4 space-y-3 text-sm text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">Series: <span className="font-semibold text-white">{form.series_key || 'Not set'}</span>{form.series_order ? ` #${form.series_order}` : ''}</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">Series title: <span className="font-semibold text-white">{form.series_title || 'Not set'}</span></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: <span className="font-semibold text-white">{form.campaign_label || form.campaign_key || 'Not set'}</span></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">Promotion tier: <span className="font-semibold text-white">{form.promotion_tier || 'None'}</span></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">Brand safety: <span className="font-semibold text-white">{form.brand_safe_status || 'Unset'}</span></div>
</div>
</section>
{mode === 'edit' ? (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Insight Links</p>
<div className="mt-4 space-y-3 text-sm text-slate-300">
{endpoints?.dashboard ? <a href={endpoints.dashboard} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 transition hover:bg-white/[0.07]"><span>Portfolio dashboard</span><i className="fa-solid fa-arrow-up-right-from-square text-slate-500" /></a> : null}
{endpoints?.analytics ? <a href={endpoints.analytics} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 transition hover:bg-white/[0.07]"><span>Collection analytics</span><i className="fa-solid fa-arrow-up-right-from-square text-slate-500" /></a> : null}
{endpoints?.history ? <a href={endpoints.history} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 transition hover:bg-white/[0.07]"><span>Audit history</span><i className="fa-solid fa-arrow-up-right-from-square text-slate-500" /></a> : null}
{endpoints?.staffSurfaces ? <a href={endpoints.staffSurfaces} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 transition hover:bg-white/[0.07]"><span>Staff surfaces</span><i className="fa-solid fa-arrow-up-right-from-square text-slate-500" /></a> : null}
{endpoints?.staffProgramming ? <a href={endpoints.staffProgramming} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 transition hover:bg-white/[0.07]"><span>Programming studio</span><i className="fa-solid fa-arrow-up-right-from-square text-slate-500" /></a> : null}
</div>
</section>
) : null}
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">v4 Guidance</p>
<div className="mt-4 space-y-3 text-sm leading-relaxed text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
Manual collections work best for hand-picked sequences, premium presentation modes, and campaign landing pages.
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
Smart collections are ideal for creator-first rulesets that keep series or editorial shelves fresh without cross-user leakage.
</div>
</div>
</section>
</aside>
</div>
) : null}
{mode !== 'edit' || activeTab === 'artworks' ? (isSmartMode ? (
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.18fr)_420px]">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Smart Builder</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Define collection rules</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-300">
Rules only evaluate your own artworks. This keeps smart collections creator-first and avoids pulling content from other users.
</p>
</div>
<button
type="button"
onClick={addRule}
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
<i className="fa-solid fa-plus fa-fw" />
Add Rule
</button>
</div>
<div className="mt-6 space-y-4">
{smartRuleCount ? smartRules.rules.map((rule, index) => (
<SmartRuleRow
key={`${rule.field}-${index}`}
rule={rule}
index={index}
smartRuleOptions={smartRuleOptions}
onFieldChange={(field) => updateSmartRule(index, () => normalizeRule({ field }))}
onOperatorChange={(operator) => updateSmartRule(index, (current) => ({ ...current, operator }))}
onValueChange={(value) => updateSmartRule(index, (current) => ({ ...current, value }))}
onRemove={() => removeRule(index)}
/>
)) : (
<div className="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center text-slate-300">
Add at least one rule to build a smart collection.
</div>
)}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Preview</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Matching artworks</h2>
</div>
<button
type="button"
onClick={handleSmartPreview}
disabled={previewing}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${previewing ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'} fa-fw`} />
Refresh Preview
</button>
</div>
<div className="mt-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
<div className="text-sm font-semibold text-white">{smartPreview?.count ?? 0} matching artworks</div>
<p className="mt-2 text-sm leading-relaxed text-slate-300">
{smartPreview?.summary || 'Preview the rules to see how this collection will fill automatically.'}
</p>
</div>
{smartPreview?.artworks?.data?.length ? (
<div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-1">
{smartPreview.artworks.data.map((artwork) => (
<SmartPreviewArtwork key={artwork.id} artwork={artwork} />
))}
</div>
) : (
<div className="mt-5 rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center">
<div className="mx-auto flex h-18 w-18 items-center justify-center rounded-[22px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-wand-magic-sparkles text-3xl" />
</div>
<h3 className="mt-5 text-xl font-semibold text-white">No matches yet</h3>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-300">
A smart collection can still be valid with zero results. Broaden the rules or publish more matching artworks.
</p>
</div>
)}
</section>
</div>
) : mode === 'edit' ? (
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.18fr)_420px]">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Attached Artworks</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Arrange the showcase order</h2>
</div>
<button
type="button"
onClick={saveArtworkOrder}
disabled={!orderDirty || saving}
className={`inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${orderDirty ? 'border-sky-300/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-grip fa-fw" />
Save Order
</button>
</div>
{attached.length ? (
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{attached.map((artwork, index) => (
<AttachedArtworkCard
key={artwork.id}
artwork={artwork}
index={index}
total={attached.length}
onMoveUp={() => moveArtwork(index, -1)}
onMoveDown={() => moveArtwork(index, 1)}
onRemove={() => handleRemoveArtwork(artwork)}
/>
))}
</div>
) : (
<div className="mt-6 rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center">
<div className="mx-auto flex h-18 w-18 items-center justify-center rounded-[22px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-images text-3xl" />
</div>
<h3 className="mt-5 text-xl font-semibold text-white">No artworks attached yet</h3>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-300">
Use the picker on the right to add artworks from your gallery. Manual ordering and cover selection become available as soon as the first piece is attached.
</p>
</div>
)}
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Artwork Picker</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add artworks</h2>
</div>
<form onSubmit={handleSearch} className="mt-5 flex gap-3">
<input
type="text"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search your gallery"
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
/>
<button
type="submit"
disabled={searching}
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${searching ? 'fa-circle-notch fa-spin' : 'fa-magnifying-glass'} fa-fw`} />
Search
</button>
</form>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-sm text-slate-300">
{selectedIds.length ? `${selectedIds.length} selected` : 'Select artworks to add'}
</div>
<button
type="button"
onClick={handleAttachSelected}
disabled={!selectedIds.length || saving}
className={`inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition ${selectedIds.length ? 'border-sky-300/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-plus fa-fw" />
Add Selected
</button>
</div>
{available.length ? (
<div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-1">
{available.map((artwork) => (
<ArtworkPickerCard
key={artwork.id}
artwork={artwork}
checked={selectedIds.includes(artwork.id)}
onToggle={toggleSelected}
/>
))}
</div>
) : (
<div className="mt-5 rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center text-slate-300">
No matching artworks available to add.
</div>
)}
</section>
</div>
) : (
<div className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center backdrop-blur-sm">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className={`fa-solid ${isSmartMode ? 'fa-wand-magic-sparkles' : 'fa-sparkles'} text-3xl`} />
</div>
<h2 className="mt-5 text-2xl font-semibold text-white">{isSmartMode ? 'Preview it, then publish it' : 'Create it first, then curate it'}</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
{isSmartMode
? 'Smart collections can be previewed before creation so you can verify the rules and tune the result set.'
: 'Once the collection is created, this page will expand into the full curator workflow with artwork selection, manual ordering, and cover controls.'}
</p>
</div>
)) : null}
{mode === 'edit' && activeTab === 'ai' ? (
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">AI Assistant</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Review-only suggestions</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Use lightweight AI-style suggestions to refine naming, summaries, covers, and section groupings. Nothing is applied until you choose it, and nothing is saved until you submit the form.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => handleAiSuggestion('title')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'title' ? 'Thinking…' : 'Suggest title'}</button>
<button type="button" onClick={() => handleAiSuggestion('summary')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'summary' ? 'Thinking…' : 'Suggest summary'}</button>
<button type="button" onClick={() => handleAiSuggestion('cover')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'cover' ? 'Thinking…' : 'Suggest cover'}</button>
<button type="button" onClick={() => handleAiSuggestion('grouping')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'grouping' ? 'Thinking…' : 'Suggest grouping'}</button>
<button type="button" onClick={() => handleAiSuggestion('relatedArtworks')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'relatedArtworks' ? 'Thinking…' : 'Suggest related artworks'}</button>
<button type="button" onClick={() => handleAiSuggestion('tags')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'tags' ? 'Thinking…' : 'Suggest tags'}</button>
<button type="button" onClick={() => handleAiSuggestion('seoDescription')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'seoDescription' ? 'Thinking…' : 'Suggest SEO description'}</button>
<button type="button" onClick={() => handleAiSuggestion('smartRulesExplanation')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'smartRulesExplanation' ? 'Thinking…' : 'Explain smart rules'}</button>
<button type="button" onClick={() => handleAiSuggestion('splitThemes')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'splitThemes' ? 'Thinking…' : 'Suggest split'}</button>
<button type="button" onClick={() => handleAiSuggestion('mergeIdea')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'mergeIdea' ? 'Thinking…' : 'Suggest merge idea'}</button>
<button type="button" onClick={handleAiQualityReview} disabled={aiState.busy !== ''} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">{aiState.busy === 'qualityReview' ? 'Reviewing…' : 'Quality review'}</button>
</div>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-2">
<AiSuggestionCard
title="Title"
body={aiState?.title?.title || 'No title suggestion yet.'}
actionLabel={aiState?.title?.title ? 'Use title' : null}
onAction={applyAiTitle}
muted={!aiState?.title?.title}
>
{aiState?.title?.alternatives?.length ? <div className="mt-3 text-xs text-slate-400">Alternatives: {aiState.title.alternatives.join(' • ')}</div> : null}
{aiState?.title?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.title.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Summary"
body={aiState?.summary?.summary || 'No summary suggestion yet.'}
actionLabel={aiState?.summary?.summary ? 'Use summary' : null}
onAction={applyAiSummary}
muted={!aiState?.summary?.summary}
>
{aiState?.summary?.seo_description ? <div className="mt-3 text-xs text-slate-400">SEO: {aiState.summary.seo_description}</div> : null}
{aiState?.summary?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.summary.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Cover"
body={aiState?.cover?.artwork?.title || 'No cover suggestion yet.'}
actionLabel={aiState?.cover?.artwork?.id && !isSmartMode ? 'Use cover' : null}
onAction={applyAiCover}
muted={!aiState?.cover?.artwork?.title}
>
{aiState?.cover?.artwork?.thumb ? <img src={aiState.cover.artwork.thumb} alt={aiState.cover.artwork.title} className="mt-4 aspect-[16/10] w-full rounded-[20px] object-cover" /> : null}
{isSmartMode && aiState?.cover?.artwork?.id ? <div className="mt-3 text-xs text-slate-500">Smart collections resolve covers dynamically, so this suggestion is preview-only.</div> : null}
{aiState?.cover?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.cover.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Grouping"
body={aiState?.grouping?.groups?.length ? 'Suggested section themes for cleaner collection storytelling.' : 'No grouping suggestion yet.'}
muted={!aiState?.grouping?.groups?.length}
>
{aiState?.grouping?.groups?.length ? (
<div className="mt-4 space-y-2">
{aiState.grouping.groups.map((group) => (
<div key={group.label} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 text-sm text-slate-200">
<div className="font-semibold text-white">{group.label}</div>
<div className="mt-1 text-xs text-slate-400">{group.count} suggested artworks</div>
</div>
))}
</div>
) : null}
{aiState?.grouping?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.grouping.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Related Artworks"
body={aiState?.relatedArtworks?.artworks?.length ? 'Suggested from your gallery to reinforce the collection theme.' : 'No related artwork suggestion yet.'}
actionLabel={aiState?.relatedArtworks?.artworks?.length ? 'Select suggestions' : null}
onAction={applyAiRelatedArtworks}
muted={!aiState?.relatedArtworks?.artworks?.length}
>
{aiState?.relatedArtworks?.artworks?.length ? (
<div className="mt-4 space-y-2">
{aiState.relatedArtworks.artworks.map((artwork) => (
<div key={artwork.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 text-sm text-slate-200">
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="h-12 w-12 rounded-xl object-cover" /> : null}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">{artwork.title}</div>
<div className="mt-1 text-xs text-slate-400">{artwork.shared_tags?.length ? `Shared tags: ${artwork.shared_tags.join(' • ')}` : `${artwork.shared_categories} shared categories`}</div>
</div>
</div>
))}
</div>
) : null}
{aiState?.relatedArtworks?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.relatedArtworks.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Suggested Tags"
body={aiState?.tags?.tags?.length ? aiState.tags.tags.join(' • ') : 'No tag suggestion yet.'}
muted={!aiState?.tags?.tags?.length}
>
{aiState?.tags?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.tags.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="SEO Description"
body={aiState?.seoDescription?.description || 'No SEO description suggestion yet.'}
muted={!aiState?.seoDescription?.description}
>
{aiState?.seoDescription?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.seoDescription.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Smart Rules"
body={aiState?.smartRulesExplanation?.explanation || 'No smart rules explanation yet.'}
muted={!aiState?.smartRulesExplanation?.explanation}
>
{aiState?.smartRulesExplanation?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.smartRulesExplanation.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Split Suggestion"
body={aiState?.splitThemes?.splits?.length ? 'Two strong theme clusters were found that could become separate collections.' : 'No split suggestion yet.'}
muted={!aiState?.splitThemes?.splits?.length}
>
{aiState?.splitThemes?.splits?.length ? (
<div className="mt-4 space-y-2">
{aiState.splitThemes.splits.map((split) => (
<div key={split.title} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 text-sm text-slate-200">
<div className="font-semibold text-white">{split.title}</div>
<div className="mt-1 text-xs text-slate-400">{split.count} suggested artworks</div>
</div>
))}
</div>
) : null}
{aiState?.splitThemes?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.splitThemes.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Merge Idea"
body={aiState?.mergeIdea?.idea?.title || 'No merge or spin-out idea yet.'}
muted={!aiState?.mergeIdea?.idea?.title}
>
{aiState?.mergeIdea?.idea?.summary ? <div className="mt-3 text-sm text-slate-300">{aiState.mergeIdea.idea.summary}</div> : null}
{aiState?.mergeIdea?.rationale ? <div className="mt-3 text-xs text-slate-500">{aiState.mergeIdea.rationale}</div> : null}
</AiSuggestionCard>
<AiSuggestionCard
title="Quality Review"
body={aiState?.qualityReview ? `Quality ${Number(aiState.qualityReview.quality_score || 0).toFixed(1)} • Ranking ${Number(aiState.qualityReview.ranking_score || 0).toFixed(1)}` : 'No quality review yet.'}
muted={!aiState?.qualityReview}
>
{aiState?.qualityReview?.missing_metadata?.length ? <div className="mt-3 text-sm text-slate-300">Missing: {aiState.qualityReview.missing_metadata.join(' • ')}</div> : null}
{aiState?.qualityReview?.suggested_summary?.summary ? <div className="mt-3 text-xs text-slate-400">Suggested summary: {aiState.qualityReview.suggested_summary.summary}</div> : null}
{aiState?.qualityReview?.suggested_related_collections?.length ? <div className="mt-3 text-xs text-slate-500">Related collections: {aiState.qualityReview.suggested_related_collections.map((item) => item.title).join(' • ')}</div> : null}
</AiSuggestionCard>
</div>
</section>
) : null}
{mode === 'edit' && activeTab === 'members' ? (
<div className="mt-8">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collaborators</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Team access</h2>
<p className="mt-2 text-sm text-slate-300">Pending invites default to {inviteExpiryDays} days, but each invite can use a shorter or longer expiry. Ownership transfers keep the previous owner as an editor.</p>
</div>
</div>
<form onSubmit={handleInviteMember} className="mt-5 flex flex-col gap-3 xl:flex-row xl:items-start">
<input value={inviteUsername} onChange={(event) => setInviteUsername(event.target.value)} placeholder="username" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="editor">Editor</option>
<option value="contributor">Contributor</option>
<option value="viewer">Viewer</option>
</select>
<div className="flex min-w-0 flex-1 flex-col gap-2 md:min-w-[240px]">
<select value={inviteExpiryMode} onChange={(event) => setInviteExpiryMode(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="default">Default ({inviteExpiryDays} days)</option>
{inviteExpiryOptions.map((days) => (
<option key={days} value={String(days)}>{days} day{days === 1 ? '' : 's'}</option>
))}
<option value="custom">Custom date</option>
</select>
{inviteExpiryMode === 'custom' ? (
<input type="datetime-local" value={inviteCustomExpiry} onChange={(event) => setInviteCustomExpiry(event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
) : (
<p className="px-1 text-xs text-slate-400">Leave this on default to use the global expiry window for collaborator invites.</p>
)}
</div>
<button type="submit" className="rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Invite</button>
</form>
<div className="mt-5 space-y-3">
{members.length ? members.map((member) => (
<MemberCard
key={member.id}
member={member}
onRoleChange={handleMemberRoleChange}
onRemove={canModerate ? handleAdminRemoveMember : handleRemoveMember}
onAccept={handleAcceptMember}
onDecline={handleDeclineMember}
onTransfer={handleTransferMember}
/>
)) : <div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-10 text-center text-sm text-slate-300">No collaborators yet.</div>}
</div>
</section>
</div>
) : null}
{mode === 'edit' && activeTab === 'submissions' ? (
<div className="mt-8">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Submissions</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Incoming artworks</h2>
</div>
<div className="mt-5 space-y-3">
{submissions.length ? submissions.map((submission) => (
<SubmissionReviewCard
key={submission.id}
submission={submission}
onApprove={(item) => handleSubmissionAction(item, 'approve')}
onReject={(item) => handleSubmissionAction(item, 'reject')}
onWithdraw={(item) => handleSubmissionAction(item, 'withdraw')}
/>
)) : <div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-10 text-center text-sm text-slate-300">No submissions yet.</div>}
</div>
</section>
</div>
) : null}
{mode === 'edit' && activeTab === 'discussion' ? (
<div className="mt-8">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Discussion</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Recent comments</h2>
</div>
<div className="mt-5 space-y-3">
{comments.length ? comments.slice(0, 6).map((comment) => (
<div key={comment.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
<div className="text-sm font-semibold text-white">{comment?.user?.display}</div>
<div className="mt-2 text-sm leading-relaxed text-slate-300" dangerouslySetInnerHTML={{ __html: comment?.rendered_content || '' }} />
</div>
)) : <div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-10 text-center text-sm text-slate-300">No comments yet.</div>}
</div>
</section>
</div>
) : null}
{mode === 'edit' && activeTab === 'settings' ? (
<div className="mt-8 space-y-6">
<div className="grid gap-6 xl:grid-cols-4">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Presentation</p>
<div className="mt-4 space-y-3 text-sm text-slate-300">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Mode</span>
<span className="font-semibold text-white">{isSmartMode ? 'Smart' : 'Manual'}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Visibility</span>
<CollectionVisibilityBadge visibility={form.visibility} />
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<span>Feature status</span>
<span className="font-semibold text-white">{featuredCountLabel}</span>
</div>
{canFeature ? (
<button
type="button"
onClick={handleToggleFeature}
disabled={featureBusy}
className={`inline-flex w-full items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${collectionState?.is_featured ? 'border-amber-300/25 bg-amber-300/10 text-amber-100 hover:bg-amber-300/15' : 'border-sky-300/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15'} disabled:cursor-not-allowed disabled:opacity-60`}
>
<i className={`fa-solid ${featureBusy ? 'fa-circle-notch fa-spin' : (collectionState?.is_featured ? 'fa-star' : 'fa-sparkles')} fa-fw`} />
{collectionState?.is_featured ? 'Remove from Featured' : 'Feature this Collection'}
</button>
) : null}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Live Stats</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<StatCard icon="fa-images" label="Artworks" value={(collectionState?.artworks_count ?? attached.length ?? 0).toLocaleString()} tone="accent" />
<StatCard icon="fa-eye" label="Views" value={(collectionState?.views_count ?? 0).toLocaleString()} />
<StatCard icon="fa-heart" label="Likes" value={(collectionState?.likes_count ?? 0).toLocaleString()} />
<StatCard icon="fa-bell" label="Followers" value={(collectionState?.followers_count ?? 0).toLocaleString()} />
</div>
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-xs leading-relaxed text-slate-400">
Shares: {(collectionState?.shares_count ?? 0).toLocaleString()} {collectionState?.last_activity_at ? `• Active ${new Date(collectionState.last_activity_at).toLocaleDateString()}` : ''}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Health</p>
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">Readiness and warnings</h2>
</div>
<button type="button" onClick={handleAiQualityReview} disabled={aiState.busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">
<i className={`fa-solid ${aiState.busy === 'qualityReview' ? 'fa-circle-notch fa-spin' : 'fa-wand-magic-sparkles'} fa-fw`} />
{aiState.busy === 'qualityReview' ? 'Reviewing…' : 'AI fix suggestions'}
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<StatCard icon="fa-heart-pulse" label="Health" value={formatStateLabel(collectionState?.health_state)} tone="accent" />
<StatCard icon="fa-clipboard-check" label="Readiness" value={formatStateLabel(collectionState?.readiness_state)} />
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.14em]">
<span className={`rounded-full border px-3 py-2 ${(collectionState?.placement_eligibility ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100')}`}>
{collectionState?.placement_eligibility ? 'Placement eligible' : 'Placement blocked'}
</span>
{collectionState?.campaign_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-slate-200">Campaign · {collectionState.campaign_key}</span> : null}
{collectionState?.program_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-slate-200">Program · {collectionState.program_key}</span> : null}
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Metadata</div>
<div className="mt-2 text-lg font-semibold text-white">{collectionState?.metadata_completeness_score !== null && collectionState?.metadata_completeness_score !== undefined ? Number(collectionState.metadata_completeness_score).toFixed(1) : 'N/A'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Editorial</div>
<div className="mt-2 text-lg font-semibold text-white">{collectionState?.editorial_readiness_score !== null && collectionState?.editorial_readiness_score !== undefined ? Number(collectionState.editorial_readiness_score).toFixed(1) : 'N/A'}</div>
</div>
</div>
<div className="mt-4 space-y-3">
{(collectionState?.health_flags?.length ? collectionState.health_flags : [collectionState?.health_state].filter(Boolean)).map((flag) => {
const meta = healthFlagMeta(flag)
return (
<div key={flag} className={`rounded-2xl border px-4 py-3 ${meta.tone}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em]">{meta.label}</div>
<div className="mt-2 text-sm leading-relaxed">{meta.description}</div>
</div>
)
})}
</div>
{aiState?.qualityReview?.missing_metadata?.length ? (
<div className="mt-4 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm text-sky-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/90">Suggested fixes</div>
<div className="mt-2">Focus first on: {aiState.qualityReview.missing_metadata.join(' • ')}</div>
{aiState?.qualityReview?.suggested_summary?.summary ? <div className="mt-2 text-xs text-sky-100/80">Suggested summary: {aiState.qualityReview.suggested_summary.summary}</div> : null}
</div>
) : null}
<div className="mt-4 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-xs leading-relaxed text-slate-400">
Last health check: {formatDateTimeLabel(collectionState?.last_health_check_at) || 'Not recorded yet'}
<br />
Recommendation refresh: {formatDateTimeLabel(collectionState?.last_recommendation_refresh_at) || 'Not recorded yet'}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Studio Notes</p>
<div className="mt-4 space-y-3 text-sm leading-relaxed text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
Use the Details tab for metadata and publishing options, then switch to Artworks for ordering or smart rule management.
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
Community and editorial collections work best when collaborators, submissions, and moderation are reviewed regularly.
</div>
</div>
</section>
</div>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Merge Review</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Compare duplicate candidates</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Review candidate overlaps, choose a canonical destination, merge when the target is final, or dismiss false positives so they stop resurfacing.
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
{duplicateCandidates.length} active candidate{duplicateCandidates.length === 1 ? '' : 's'}
</div>
</div>
{canonicalTarget ? (
<div className="mt-6 rounded-[26px] border border-amber-300/20 bg-amber-400/10 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Current canonical target</p>
<p className="mt-2 text-lg font-semibold text-white">{canonicalTarget.title}</p>
<p className="mt-1 text-sm text-amber-100/80">{canonicalTarget.owner?.name || canonicalTarget.owner?.username || 'Collection'}</p>
</div>
{canonicalTarget.manage_url ? <a href={canonicalTarget.manage_url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.1]">Open target<i className="fa-solid fa-arrow-up-right-from-square fa-fw text-slate-400" /></a> : null}
</div>
</div>
) : null}
<div className="mt-6 space-y-4">
{duplicateCandidates.length ? duplicateCandidates.map((candidate) => (
<div key={`merge-candidate-${candidate.collection?.id}`} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<div>
{candidate.collection ? <CollectionCard collection={candidate.collection} isOwner /> : null}
</div>
<div className="space-y-3 text-sm text-slate-300">
<div className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Compare</div>
<div className="mt-2 flex flex-wrap gap-2">
{(candidate.comparison?.match_reasons || []).map((reason) => (
<span key={reason} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{reason.replaceAll('_', ' ')}</span>
))}
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-slate-400">
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Shared artworks: <span className="font-semibold text-white">{candidate.comparison?.shared_artworks_count ?? 0}</span></div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Current: <span className="font-semibold text-white">{candidate.comparison?.source_artworks_count ?? 0}</span></div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Target: <span className="font-semibold text-white">{candidate.comparison?.target_artworks_count ?? 0}</span></div>
</div>
</div>
{candidate.decision?.action_type ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-xs text-slate-400">
Latest decision: <span className="font-semibold uppercase tracking-[0.14em] text-white">{candidate.decision.action_type.replaceAll('_', ' ')}</span>
{candidate.decision.summary ? <div className="mt-1">{candidate.decision.summary}</div> : null}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleCanonicalizeCandidate(candidate)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-bullseye fa-fw" />
Canonicalize here
</button>
<button
type="button"
onClick={() => handleMergeCandidate(candidate)}
disabled={saving || candidate.collection?.mode === 'smart'}
className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-code-merge fa-fw" />
Merge into target
</button>
<button
type="button"
onClick={() => handleRejectDuplicateCandidate(candidate)}
disabled={saving || candidate.is_current_canonical_target}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-ban fa-fw" />
Not duplicate
</button>
</div>
{candidate.collection?.mode === 'smart' ? <div className="text-xs text-amber-200/80">Smart collections can be designated as canonical but cannot receive merged artworks directly.</div> : null}
</div>
</div>
</div>
)) : (
<div className="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center text-sm text-slate-300">
No active duplicate candidates right now. Dismissed pairs stay suppressed until the collections change again.
</div>
)}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Linked Collections</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Manual related collection links</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Curate a hand-picked strip of related collections for multi-part series, anthologies, and campaign bundles. These links render before recommendation-driven suggestions on the public page.
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
{linkedCollections.length} linked
</div>
</div>
<div className="mt-6 flex flex-col gap-3 xl:flex-row xl:items-end">
<div className="min-w-0 flex-1">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Add manageable collection</label>
<select
value={selectedLinkedCollectionId}
onChange={(event) => setSelectedLinkedCollectionId(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="">Select a collection</option>
{linkedCollectionOptions.map((item) => (
<option key={item.id} value={item.id}>{item.title}</option>
))}
</select>
</div>
<button
type="button"
onClick={handleAddLinkedCollection}
disabled={!selectedLinkedCollectionId || saving}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-link'} fa-fw`} />
Add linked collection
</button>
</div>
<div className="mt-6 space-y-3">
{linkedCollections.length ? linkedCollections.map((item) => (
<div key={item.id} className="flex flex-col gap-4 rounded-[26px] border border-white/10 bg-white/[0.04] p-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-4">
{item.cover_image ? <img src={item.cover_image} alt={item.title} className="h-16 w-16 rounded-[18px] object-cover ring-1 ring-white/10" /> : <div className="flex h-16 w-16 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-slate-400"><i className="fa-solid fa-grid-2" /></div>}
<div className="min-w-0">
<div className="truncate text-base font-semibold text-white">{item.title}</div>
<div className="mt-1 truncate text-sm text-slate-400">{item.campaign_label || item.series_key || item.owner?.name || item.owner?.username || 'Collection'}</div>
</div>
</div>
<div className="flex items-center gap-3">
{item.manage_url ? <a href={item.manage_url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Open<i className="fa-solid fa-arrow-up-right-from-square fa-fw text-slate-500" /></a> : null}
<button
type="button"
onClick={() => handleRemoveLinkedCollection(item.id)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-link-slash fa-fw" />
Remove
</button>
</div>
</div>
)) : (
<div className="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center text-sm text-slate-300">
No manual linked collections yet. Add sequels, anthologies, or campaign companions here.
</div>
)}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Cross-Entity Links</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Creators, artworks, stories, categories, campaigns, events, and tags</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Add contextual links that turn this collection into a richer destination page. These links can point to creators, public artworks, published stories, category landings, campaign or event context, and public tag or theme pages.
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
{entityLinks.length} linked entities
</div>
</div>
<div className="mt-6 grid gap-3 xl:grid-cols-[180px_minmax(0,1fr)_240px_auto] xl:items-end">
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Entity type</label>
<select
value={selectedEntityType}
onChange={(event) => {
const nextType = event.target.value
const nextOptions = Array.isArray(entityLinkOptions[nextType]) ? entityLinkOptions[nextType] : []
setSelectedEntityType(nextType)
setSelectedEntityId(nextOptions[0]?.id || '')
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="creator">Creator</option>
<option value="artwork">Artwork</option>
<option value="story">Story</option>
<option value="category">Category</option>
<option value="campaign">Campaign</option>
<option value="event">Event</option>
<option value="tag">Tag or Theme</option>
</select>
</div>
<div className="min-w-0">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Choose entity</label>
<select
value={selectedEntityId}
onChange={(event) => setSelectedEntityId(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="">Select an entity</option>
{(entityLinkOptions[selectedEntityType] || []).map((item) => (
<option key={`${selectedEntityType}-${item.id}`} value={item.id}>{item.label}</option>
))}
</select>
</div>
<div className="min-w-0">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Relationship label</label>
<input
value={entityRelationship}
onChange={(event) => setEntityRelationship(event.target.value)}
placeholder="featured creator"
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
maxLength={80}
/>
</div>
<button
type="button"
onClick={handleAddEntityLink}
disabled={!selectedEntityType || !selectedEntityId || saving}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-lime-300/25 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-diagram-project'} fa-fw`} />
Add link
</button>
</div>
<div className="mt-6 space-y-3">
{entityLinks.length ? entityLinks.map((item) => (
<div key={`${item.linked_type}-${item.linked_id}-${item.id}`} className="flex flex-col gap-4 rounded-[26px] border border-white/10 bg-white/[0.04] p-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-4">
{item.image_url ? <img src={item.image_url} alt={item.title} className="h-16 w-16 rounded-[18px] object-cover ring-1 ring-white/10" /> : <div className="flex h-16 w-16 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-slate-400"><i className="fa-solid fa-diagram-project" /></div>}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-base font-semibold text-white">{item.title}</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.meta}</span>
</div>
<div className="mt-1 truncate text-sm text-slate-400">{item.subtitle}</div>
{item.relationship_type ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-lime-200/80">{item.relationship_type}</div> : null}
</div>
</div>
<div className="flex items-center gap-3">
{item.url ? <a href={item.url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Open<i className="fa-solid fa-arrow-up-right-from-square fa-fw text-slate-500" /></a> : null}
<button
type="button"
onClick={() => handleRemoveEntityLink(item.id)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-link-slash fa-fw" />
Remove
</button>
</div>
</div>
)) : (
<div className="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-center text-sm text-slate-300">
No entity links yet. Connect this collection to a creator profile, a public artwork, a published story, campaign or event context, or a category landing page.
</div>
)}
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Page Modules</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Persisted collection layout</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Reorder the collection page with predefined modules. This keeps the destination page modular without turning it into a freeform page builder.
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
{layoutModules.filter((module) => module.enabled).length} active modules
</div>
</div>
<div className="mt-6 space-y-4">
{layoutModules.map((module, index) => (
<LayoutModuleCard
key={module.key}
module={module}
index={index}
total={layoutModules.length}
onToggle={(key, enabled) => updateLayoutModule(key, { enabled })}
onSlotChange={(key, slot) => updateLayoutModule(key, { slot })}
onMoveUp={() => moveLayoutModule(index, -1)}
onMoveDown={() => moveLayoutModule(index, 1)}
/>
))}
</div>
</section>
</div>
) : null}
{mode === 'edit' && activeTab === 'moderation' ? (
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Moderation</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Admin controls</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Restrict public visibility, disable risky interactions, unfeature collections, or remove collaborators when a curation surface needs intervention.
</p>
</div>
<div className="mt-6 grid gap-5 xl:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<Field label="Moderation Status">
<select
value={collectionState?.moderation_status || 'active'}
onChange={(event) => handleModerationStatusChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
<option value="active">Active</option>
<option value="under_review">Under review</option>
<option value="restricted">Restricted</option>
<option value="hidden">Hidden</option>
</select>
</Field>
<div className="mt-4 space-y-3">
<label className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<span>Allow comments</span>
<input type="checkbox" checked={form.allow_comments} onChange={(event) => handleModerationToggle('allow_comments', event.target.checked)} />
</label>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<span>Allow submissions</span>
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => handleModerationToggle('allow_submissions', event.target.checked)} />
</label>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<span>Allow saves</span>
<input type="checkbox" checked={form.allow_saves} onChange={(event) => handleModerationToggle('allow_saves', event.target.checked)} />
</label>
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Rapid actions</div>
<div className="mt-4 flex flex-wrap gap-3">
<button type="button" onClick={handleAdminUnfeature} className="rounded-2xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/15">
Remove featured placement
</button>
<button type="button" onClick={() => handleModerationStatusChange('under_review')} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
Send to review
</button>
<button type="button" onClick={() => handleModerationStatusChange('restricted')} className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15">
Restrict public access
</button>
</div>
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-4 text-sm leading-relaxed text-slate-300">
Current state: <span className="font-semibold text-white">{(collectionState?.moderation_status || 'active').replace('_', ' ')}</span>
</div>
</div>
</div>
</section>
) : null}
</div>
</div>
</>
)
}