2633 lines
140 KiB
JavaScript
2633 lines
140 KiB
JavaScript
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||
import { usePage, Link } from '@inertiajs/react'
|
||
import StudioLayout from '../../Layouts/StudioLayout'
|
||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||
import TextInput from '../../components/ui/TextInput'
|
||
import Button from '../../components/ui/Button'
|
||
import Modal from '../../components/ui/Modal'
|
||
import FormField from '../../components/ui/FormField'
|
||
import Toggle from '../../components/ui/Toggle'
|
||
import NovaSelect from '../../components/ui/NovaSelect'
|
||
import TagPicker from '../../components/tags/TagPicker'
|
||
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
|
||
import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker'
|
||
import WorldSubmissionSelector from '../../components/worlds/WorldSubmissionSelector'
|
||
|
||
const EDIT_SECTIONS = [
|
||
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
|
||
{ id: 'details', label: 'Details', hint: 'Title and description' },
|
||
{ id: 'evolution', label: 'Evolution', hint: 'Link an older original artwork' },
|
||
{ id: 'ai-assist', label: 'AI Assist', hint: 'Suggestions and similar matches' },
|
||
{ id: 'tags', label: 'Tags', hint: 'Search, add, and refine keywords' },
|
||
{ id: 'worlds', label: 'Worlds', hint: 'Community submissions and review state' },
|
||
{ id: 'visibility', label: 'Visibility', hint: 'Publishing state' },
|
||
]
|
||
|
||
const TABS = [
|
||
{ id: 'details', label: 'Details', icon: 'fa-solid fa-pen-fancy' },
|
||
{ id: 'media', label: 'Media', icon: 'fa-solid fa-photo-film' },
|
||
{ id: 'evolution', label: 'Evolution', icon: 'fa-solid fa-code-branch' },
|
||
{ id: 'tags', label: 'Tags', icon: 'fa-solid fa-tags' },
|
||
{ id: 'worlds', label: 'Worlds', icon: 'fa-solid fa-globe' },
|
||
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
|
||
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
|
||
{ id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||
]
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
function getCsrfToken() {
|
||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes) return '—'
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||
}
|
||
|
||
function resolveFileExtension(fileName, fallbackExt = '') {
|
||
const normalizedFallback = String(fallbackExt || '').trim().replace(/^\./, '').toLowerCase()
|
||
const normalizedName = String(fileName || '').trim()
|
||
const fromName = normalizedName.includes('.')
|
||
? normalizedName.split('.').pop()?.trim().toLowerCase()
|
||
: ''
|
||
|
||
return fromName || normalizedFallback
|
||
}
|
||
|
||
function isArchiveArtwork(fileName, mimeType, fileExt) {
|
||
const extension = resolveFileExtension(fileName, fileExt)
|
||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return true
|
||
|
||
const normalizedMime = String(mimeType || '').toLowerCase()
|
||
return normalizedMime.includes('zip')
|
||
|| normalizedMime.includes('rar')
|
||
|| normalizedMime.includes('7z')
|
||
|| normalizedMime.includes('tar')
|
||
|| normalizedMime.includes('gzip')
|
||
}
|
||
|
||
function formatSchedulePreview(value, timezone) {
|
||
if (!value) return 'Pick a date and time'
|
||
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return 'Pick a date and time'
|
||
|
||
try {
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
timeZone: timezone || undefined,
|
||
}).format(date)
|
||
} catch {
|
||
return date.toLocaleString()
|
||
}
|
||
}
|
||
|
||
function formatReleaseCountdown(value, nowMs = Date.now()) {
|
||
if (!value) return ''
|
||
|
||
const releaseDate = new Date(value)
|
||
if (Number.isNaN(releaseDate.getTime())) return ''
|
||
|
||
const remainingMs = releaseDate.getTime() - nowMs
|
||
|
||
if (remainingMs <= 0) {
|
||
return 'Releasing now'
|
||
}
|
||
|
||
const totalSeconds = Math.floor(remainingMs / 1000)
|
||
const days = Math.floor(totalSeconds / 86400)
|
||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||
const seconds = totalSeconds % 60
|
||
|
||
const parts = []
|
||
|
||
if (days > 0) parts.push(`${days}d`)
|
||
if (days > 0 || hours > 0) parts.push(`${hours}h`)
|
||
if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`)
|
||
if (days === 0) parts.push(`${seconds}s`)
|
||
|
||
return `In ${parts.join(' ')}`
|
||
}
|
||
|
||
function getContentTypeVisualKey(slug) {
|
||
const map = { skins: 'skins', wallpapers: 'wallpapers', photography: 'photography', other: 'other', members: 'members' }
|
||
return map[slug] || 'other'
|
||
}
|
||
|
||
function buildCategoryTree(contentTypes) {
|
||
return (contentTypes || []).map((ct) => ({
|
||
...ct,
|
||
rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({
|
||
...rc,
|
||
children: rc.children || [],
|
||
})),
|
||
}))
|
||
}
|
||
|
||
function nextSourceForManualEdit(currentSource) {
|
||
if (currentSource === 'ai_applied' || currentSource === 'ai_generated') return 'mixed'
|
||
if (currentSource === 'mixed') return 'mixed'
|
||
return 'manual'
|
||
}
|
||
|
||
function statusTone(status) {
|
||
switch (status) {
|
||
case 'ready':
|
||
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200'
|
||
case 'queued':
|
||
case 'processing':
|
||
return 'border-sky-400/30 bg-sky-400/10 text-sky-200'
|
||
case 'failed':
|
||
return 'border-red-400/30 bg-red-400/10 text-red-200'
|
||
default:
|
||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||
}
|
||
}
|
||
|
||
function statusLabel(status) {
|
||
switch (status) {
|
||
case 'queued':
|
||
return 'Queued'
|
||
case 'processing':
|
||
return 'Processing'
|
||
case 'ready':
|
||
return 'Ready'
|
||
case 'failed':
|
||
return 'Failed'
|
||
case 'pending':
|
||
return 'Pending'
|
||
default:
|
||
return 'Not analyzed'
|
||
}
|
||
}
|
||
|
||
function visibilityLabel(value) {
|
||
switch (value) {
|
||
case 'unlisted':
|
||
return 'Unlisted'
|
||
case 'private':
|
||
return 'Private'
|
||
default:
|
||
return 'Public'
|
||
}
|
||
}
|
||
|
||
function normalizeContributorCredits(contributorIds = [], contributorCredits = {}) {
|
||
const normalized = {}
|
||
const ids = Array.isArray(contributorIds)
|
||
? contributorIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
|
||
: []
|
||
|
||
ids.forEach((id) => {
|
||
const current = contributorCredits?.[id] || contributorCredits?.[String(id)] || {}
|
||
normalized[id] = {
|
||
creditRole: typeof current.creditRole === 'string' ? current.creditRole : '',
|
||
isPrimary: Boolean(current.isPrimary),
|
||
}
|
||
})
|
||
|
||
const leadIds = Object.entries(normalized)
|
||
.filter(([, value]) => value.isPrimary)
|
||
.map(([id]) => Number(id))
|
||
|
||
if (leadIds.length > 1) {
|
||
leadIds.slice(1).forEach((id) => {
|
||
normalized[id] = {
|
||
...normalized[id],
|
||
isPrimary: false,
|
||
}
|
||
})
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
function mapContributorCredits(contributorCredits = []) {
|
||
return (Array.isArray(contributorCredits) ? contributorCredits : []).reduce((accumulator, contributor) => {
|
||
const userId = Number(contributor?.user_id)
|
||
if (!Number.isFinite(userId) || userId <= 0) return accumulator
|
||
|
||
accumulator[userId] = {
|
||
creditRole: typeof contributor?.credit_role === 'string' ? contributor.credit_role : '',
|
||
isPrimary: Boolean(contributor?.is_primary),
|
||
}
|
||
|
||
return accumulator
|
||
}, {})
|
||
}
|
||
|
||
function normalizeWorldSubmissionOptions(options = []) {
|
||
return (Array.isArray(options) ? options : []).map((world) => ({
|
||
...world,
|
||
selected: Boolean(world?.selected),
|
||
note: typeof world?.note === 'string' ? world.note : '',
|
||
}))
|
||
}
|
||
|
||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||
|
||
/** Glass-morphism section card (Nova theme) */
|
||
function Section({ children, className = '', id = undefined }) {
|
||
return (
|
||
<section id={id} className={`scroll-mt-24 bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/** Section heading */
|
||
function SectionTitle({ icon, children }) {
|
||
return (
|
||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">
|
||
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
|
||
{children}
|
||
</h3>
|
||
)
|
||
}
|
||
|
||
function InlineAiButton({ children, onClick, disabled = false, loading = false }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className="inline-flex items-center gap-1 rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold text-sky-200 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<span>{loading ? '...' : '✦'}</span>
|
||
<span>{children}</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function FieldLabel({ label, actionLabel, onAction, disabled = false, loading = false }) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{label}</span>
|
||
<InlineAiButton onClick={onAction} disabled={disabled} loading={loading}>{actionLabel}</InlineAiButton>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RightRailCard({ title, children, className = '' }) {
|
||
return (
|
||
<div className={`rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.16),_rgba(15,23,42,0.92)_62%)] p-4 ${className}`}>
|
||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{title}</h3>
|
||
<div className="mt-3">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||
|
||
export default function StudioArtworkEdit() {
|
||
const { props } = usePage()
|
||
const { artwork, contentTypes: rawContentTypes } = props
|
||
const groupOptions = Array.isArray(props.groupOptions) ? props.groupOptions : []
|
||
const evolutionRelationTypes = Array.isArray(props.evolutionRelationTypes) ? props.evolutionRelationTypes : []
|
||
const initialEvolutionRelation = artwork?.evolution_relation || null
|
||
const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object'
|
||
? props.contributorOptionsByGroup
|
||
: {}
|
||
const initialWorldSubmissionOptions = Array.isArray(props.worldSubmissionOptions) ? props.worldSubmissionOptions : []
|
||
|
||
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
|
||
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null)
|
||
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
|
||
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
|
||
const [title, setTitle] = useState(artwork?.title || '')
|
||
const [description, setDescription] = useState(artwork?.description || '')
|
||
const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name))
|
||
const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private'))
|
||
const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now'))
|
||
const [scheduledAt, setScheduledAt] = useState(artwork?.publish_at || null)
|
||
const [groupSlug, setGroupSlug] = useState(artwork?.group_slug || '')
|
||
const [primaryAuthorUserId, setPrimaryAuthorUserId] = useState(artwork?.primary_author_user_id || null)
|
||
const [contributorUserIds, setContributorUserIds] = useState(() => (Array.isArray(artwork?.contributor_user_ids) ? artwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : []))
|
||
const [contributorCredits, setContributorCredits] = useState(() => normalizeContributorCredits(artwork?.contributor_user_ids || [], mapContributorCredits(artwork?.contributor_credits || [])))
|
||
const [worldSubmissionOptions, setWorldSubmissionOptions] = useState(() => normalizeWorldSubmissionOptions(initialWorldSubmissionOptions))
|
||
const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual')
|
||
const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual')
|
||
const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual')
|
||
const [categorySource, setCategorySource] = useState(artwork?.category_source || 'manual')
|
||
const [evolutionTarget, setEvolutionTarget] = useState(initialEvolutionRelation?.target_artwork || null)
|
||
const [evolutionRelationType, setEvolutionRelationType] = useState(initialEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of')
|
||
const [evolutionNote, setEvolutionNote] = useState(initialEvolutionRelation?.note || '')
|
||
const [saving, setSaving] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
const [errors, setErrors] = useState({})
|
||
const [aiData, setAiData] = useState(null)
|
||
const [aiLoading, setAiLoading] = useState(false)
|
||
const [aiAction, setAiAction] = useState('')
|
||
const [aiDirect, setAiDirect] = useState(false)
|
||
const [isAiPanelOpen, setIsAiPanelOpen] = useState(true)
|
||
const [selectedAiTags, setSelectedAiTags] = useState([])
|
||
const [activeTab, setActiveTab] = useState('details')
|
||
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !artwork?.parent_category_id)
|
||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||
const userTimezone = useMemo(() => artwork?.artwork_timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [artwork?.artwork_timezone])
|
||
|
||
// File replace
|
||
const fileInputRef = useRef(null)
|
||
const [replacing, setReplacing] = useState(false)
|
||
const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null)
|
||
const downloadUrl = artwork?.download_url || (artwork?.id ? `/download/artwork/${artwork.id}` : null)
|
||
const [selectedMediaId, setSelectedMediaId] = useState('cover')
|
||
const [fileExt, setFileExt] = useState(artwork?.file_ext || '')
|
||
const [mimeType, setMimeType] = useState(artwork?.mime_type || '')
|
||
const [hasArchiveFile, setHasArchiveFile] = useState(Boolean(artwork?.has_archive_file))
|
||
const [artworkScreenshots, setArtworkScreenshots] = useState(() => (Array.isArray(artwork?.screenshots) ? artwork.screenshots : []))
|
||
const [fileMeta, setFileMeta] = useState({
|
||
name: artwork?.file_name || '—',
|
||
size: artwork?.file_size || 0,
|
||
width: artwork?.width || 0,
|
||
height: artwork?.height || 0,
|
||
})
|
||
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
|
||
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
|
||
const [changeNote, setChangeNote] = useState('')
|
||
const [showChangeNote, setShowChangeNote] = useState(false)
|
||
|
||
// Version history
|
||
const [showHistory, setShowHistory] = useState(false)
|
||
const [historyData, setHistoryData] = useState(null)
|
||
const [historyLoading, setHistoryLoading] = useState(false)
|
||
const [restoring, setRestoring] = useState(null)
|
||
const [archiveRevisionSaving, setArchiveRevisionSaving] = useState(false)
|
||
const [archiveRevisionError, setArchiveRevisionError] = useState('')
|
||
const [archiveCoverFile, setArchiveCoverFile] = useState(null)
|
||
const [archiveCoverPreview, setArchiveCoverPreview] = useState(null)
|
||
const [archivePackageFile, setArchivePackageFile] = useState(null)
|
||
const [archiveExtraScreenshots, setArchiveExtraScreenshots] = useState([])
|
||
const [archiveExtraPreviews, setArchiveExtraPreviews] = useState([])
|
||
// Per-slot screenshot replacement: { slotIndex: File }
|
||
const [replaceShots, setReplaceShots] = useState({})
|
||
const [replaceShotPreviews, setReplaceShotPreviews] = useState({})
|
||
const [removedShots, setRemovedShots] = useState({})
|
||
// Staged single-image replace (no auto-upload)
|
||
const [pendingReplaceFile, setPendingReplaceFile] = useState(null)
|
||
const [pendingReplacePreview, setPendingReplacePreview] = useState(null)
|
||
// Drag-over tracking for drop zones
|
||
const [dragOverZone, setDragOverZone] = useState(null)
|
||
const screenshotItems = artworkScreenshots
|
||
const activeScreenshotCount = screenshotItems.filter((_, index) => !removedShots[index]).length
|
||
const currentFileExt = resolveFileExtension(fileMeta.name, fileExt)
|
||
const archiveArtwork = hasArchiveFile || isArchiveArtwork(fileMeta.name, mimeType, fileExt)
|
||
const quickReplaceSupported = !archiveArtwork
|
||
const mediaItems = useMemo(() => {
|
||
const coverItem = {
|
||
id: 'cover',
|
||
label: archiveArtwork ? 'Cover preview' : 'Main artwork',
|
||
url: thumbUrl,
|
||
width: fileMeta.width || 0,
|
||
height: fileMeta.height || 0,
|
||
}
|
||
|
||
const screenshotMedia = screenshotItems.map((item, index) => ({
|
||
id: item.id || `shot-${index + 1}`,
|
||
label: item.label || `Screenshot ${index + 1}`,
|
||
url: item.thumb_url || item.url || null,
|
||
width: 0,
|
||
height: 0,
|
||
}))
|
||
|
||
return [coverItem, ...screenshotMedia].filter((item) => Boolean(item.url))
|
||
}, [archiveArtwork, fileMeta.height, fileMeta.width, screenshotItems, thumbUrl])
|
||
const activeMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null
|
||
const activeMediaLabel = activeMedia?.label || (archiveArtwork ? 'Cover preview' : 'Main artwork')
|
||
|
||
// ── Derived ────────────────────────────────────────────────────────────────
|
||
const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null
|
||
const rootCategories = selectedCT?.rootCategories || []
|
||
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
|
||
const subCategories = selectedRoot?.children || []
|
||
const selectedGroupOption = useMemo(() => groupOptions.find((group) => String(group.slug || '') === String(groupSlug || '')) || null, [groupOptions, groupSlug])
|
||
const currentContributorOptions = useMemo(() => {
|
||
const selectedSlug = String(groupSlug || '')
|
||
return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : []
|
||
}, [contributorOptionsByGroup, groupSlug])
|
||
const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed'
|
||
const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData])
|
||
const selectedLeafCategoryId = subCategoryId || categoryId || null
|
||
const visibilitySummary = publishMode === 'schedule'
|
||
? `Scheduled as ${visibilityLabel(visibility)}`
|
||
: visibilityLabel(visibility)
|
||
const selectedSubCategory = subCategoryId ? subCategories.find((item) => item.id === subCategoryId) || null : null
|
||
const heroMeta = [
|
||
selectedCT?.name || 'No content type',
|
||
selectedRoot?.name || 'No root category',
|
||
selectedSubCategory?.name || null,
|
||
].filter(Boolean)
|
||
const categoryPreviewSummary = [selectedCT?.name, selectedRoot?.name, selectedSubCategory?.name].filter(Boolean).join(' / ') || 'Choose a category path'
|
||
const visibilityPreviewHint = publishMode === 'schedule'
|
||
? 'Hidden until the scheduled publish time.'
|
||
: visibility === 'private'
|
||
? 'Draft-only visibility.'
|
||
: visibility === 'unlisted'
|
||
? 'Accessible by direct link.'
|
||
: 'Visible to everyone immediately.'
|
||
const hasScheduledRelease = publishMode === 'schedule' && Boolean(scheduledAt)
|
||
const schedulePreviewSummary = hasScheduledRelease
|
||
? formatReleaseCountdown(scheduledAt, nowMs)
|
||
: ''
|
||
const schedulePreviewHint = hasScheduledRelease
|
||
? formatSchedulePreview(scheduledAt, userTimezone)
|
||
: ''
|
||
const publishingIdentityOptions = useMemo(() => {
|
||
const personalOption = {
|
||
value: '',
|
||
label: 'Personal profile',
|
||
icon: <i className="fa-solid fa-user text-[11px] text-sky-200" aria-hidden="true" />,
|
||
contextLabel: 'Publish under your own creator identity',
|
||
}
|
||
|
||
const groupItems = groupOptions.map((group) => ({
|
||
value: group.slug,
|
||
label: group.name,
|
||
icon: <i className="fa-solid fa-users text-[11px] text-violet-200" aria-hidden="true" />,
|
||
contextLabel: 'Publish under the shared group identity',
|
||
}))
|
||
|
||
return [personalOption, ...groupItems]
|
||
}, [groupOptions])
|
||
const primaryAuthorOptions = useMemo(() => currentContributorOptions.map((user) => ({
|
||
value: Number(user.id),
|
||
label: user.name || user.username,
|
||
username: user.username,
|
||
avatarUrl: user.avatar_url || null,
|
||
})), [currentContributorOptions])
|
||
const selectedEvolutionType = evolutionRelationTypes.find((option) => String(option.value) === String(evolutionRelationType)) || evolutionRelationTypes[0] || null
|
||
|
||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||
const handleContentTypeChange = (id) => {
|
||
setContentTypeId(id)
|
||
setCategoryId(null)
|
||
setSubCategoryId(null)
|
||
setIsCategoryChooserOpen(true)
|
||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||
}
|
||
|
||
const handleCategoryChange = (id) => {
|
||
setCategoryId(id)
|
||
setSubCategoryId(null)
|
||
setIsCategoryChooserOpen(false)
|
||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||
}
|
||
|
||
const handleSubCategoryChange = (id) => {
|
||
setSubCategoryId(id)
|
||
setCategorySource((current) => nextSourceForManualEdit(current))
|
||
}
|
||
|
||
const handleTitleChange = (e) => {
|
||
setTitle(e.target.value)
|
||
setTitleSource((current) => nextSourceForManualEdit(current))
|
||
}
|
||
|
||
const handleDescriptionChange = (value) => {
|
||
setDescription(value)
|
||
setDescriptionSource((current) => nextSourceForManualEdit(current))
|
||
}
|
||
|
||
const handleTagChange = (nextTags) => {
|
||
setTagSlugs(nextTags)
|
||
setTagsSource((current) => nextSourceForManualEdit(current))
|
||
}
|
||
|
||
const loadAiData = useCallback(async (silent = false) => {
|
||
if (!artwork?.id) return
|
||
if (!silent) setAiLoading(true)
|
||
try {
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai`, {
|
||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
})
|
||
if (!res.ok) return
|
||
const data = await res.json()
|
||
setAiData(data.data || null)
|
||
setSelectedAiTags((data.data?.tag_suggestions || []).map((item) => item.tag).filter(Boolean))
|
||
} catch (err) {
|
||
console.error('AI assist load failed:', err)
|
||
} finally {
|
||
if (!silent) setAiLoading(false)
|
||
}
|
||
}, [artwork?.id])
|
||
|
||
const syncCurrentPayload = useCallback((current) => {
|
||
if (!current) return
|
||
setTitle(current.title || '')
|
||
setDescription(current.description || '')
|
||
setTagSlugs(Array.isArray(current.tags) ? current.tags : [])
|
||
setContentTypeId(current.content_type_id || null)
|
||
setCategoryId(current.category_id || null)
|
||
setSubCategoryId(null)
|
||
setTitleSource(current.sources?.title || 'manual')
|
||
setDescriptionSource(current.sources?.description || 'manual')
|
||
setTagsSource(current.sources?.tags || 'manual')
|
||
setCategorySource(current.sources?.category || 'manual')
|
||
}, [])
|
||
|
||
const trackAiEvent = useCallback(async (eventType, meta = {}) => {
|
||
if (!artwork?.id) return
|
||
try {
|
||
await fetch(`/api/studio/artworks/${artwork.id}/ai/events`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ event_type: eventType, meta }),
|
||
})
|
||
} catch (err) {
|
||
console.error('AI event track failed:', err)
|
||
}
|
||
}, [artwork?.id])
|
||
|
||
const triggerAi = useCallback(async (action = 'analyze', options = {}) => {
|
||
if (!artwork?.id) return
|
||
setAiAction(action)
|
||
try {
|
||
const direct = typeof options.direct === 'boolean' ? options.direct : aiDirect
|
||
const intent = options.intent || 'analyze'
|
||
const requestBody = { direct, intent }
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/${action}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(requestBody),
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
if (direct && data?.data) {
|
||
setAiData(data.data)
|
||
} else {
|
||
await loadAiData(true)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('AI assist request failed:', err)
|
||
} finally {
|
||
setAiAction('')
|
||
}
|
||
}, [aiDirect, artwork?.id, loadAiData])
|
||
|
||
const persistAiAction = useCallback(async (payload) => {
|
||
if (!artwork?.id) return
|
||
setAiAction('apply')
|
||
try {
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/apply`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
if (data?.data) {
|
||
setAiData(data.data)
|
||
syncCurrentPayload(data.data.current)
|
||
setSelectedAiTags((data.data.tag_suggestions || []).map((item) => item.tag).filter(Boolean))
|
||
} else {
|
||
await loadAiData(true)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('AI assist apply failed:', err)
|
||
} finally {
|
||
setAiAction('')
|
||
}
|
||
}, [artwork?.id, loadAiData, syncCurrentPayload])
|
||
|
||
const copyText = useCallback(async (value) => {
|
||
if (!value) return
|
||
try {
|
||
await window.navigator?.clipboard?.writeText(value)
|
||
trackAiEvent('suggestion_copied', { length: value.length })
|
||
} catch (err) {
|
||
console.error('Clipboard write failed:', err)
|
||
}
|
||
}, [trackAiEvent])
|
||
|
||
const applyTitleSuggestion = useCallback((value, mode = 'replace') => {
|
||
persistAiAction({ title: value, title_mode: mode })
|
||
}, [persistAiAction])
|
||
|
||
const applyDescriptionSuggestion = useCallback((value, mode = 'replace') => {
|
||
persistAiAction({ description: value, description_mode: mode })
|
||
}, [persistAiAction])
|
||
|
||
const applyTagSuggestions = useCallback((values, mode = 'add') => {
|
||
const normalized = Array.isArray(values) ? values.filter(Boolean) : []
|
||
if (normalized.length === 0) return
|
||
persistAiAction({ tags: normalized, tag_mode: mode })
|
||
}, [persistAiAction])
|
||
|
||
const applyCategorySuggestion = useCallback((suggestion, mode = 'both') => {
|
||
if (!suggestion) return
|
||
const payload = {}
|
||
if (mode === 'content_type' || mode === 'both') {
|
||
payload.content_type_id = suggestion.content_type_id || suggestion.id || null
|
||
}
|
||
if (mode === 'category' || mode === 'both') {
|
||
payload.category_id = suggestion.root_category_id || suggestion.id || null
|
||
}
|
||
persistAiAction(payload)
|
||
}, [persistAiAction])
|
||
|
||
const toggleSuggestedTag = useCallback((tag) => {
|
||
if (!tag) return
|
||
setSelectedAiTags((current) => current.includes(tag)
|
||
? current.filter((item) => item !== tag)
|
||
: [...current, tag])
|
||
}, [])
|
||
|
||
const handleImproveAll = useCallback(() => {
|
||
if (aiStatus !== 'ready') {
|
||
triggerAi('analyze', { intent: 'analyze' })
|
||
return
|
||
}
|
||
const bestTitle = aiData?.title_suggestions?.[0]?.text
|
||
const bestDescription = aiData?.description_suggestions?.find((item) => item.variant === 'normal')?.text
|
||
|| aiData?.description_suggestions?.[0]?.text
|
||
const bestCategory = aiData?.category
|
||
const payload = {}
|
||
if (bestTitle) {
|
||
payload.title = bestTitle
|
||
payload.title_mode = 'replace'
|
||
}
|
||
if (bestDescription) {
|
||
payload.description = bestDescription
|
||
payload.description_mode = 'replace'
|
||
}
|
||
if (aiSuggestedTags.length > 0) {
|
||
payload.tags = aiSuggestedTags
|
||
payload.tag_mode = 'add'
|
||
}
|
||
if (bestCategory?.content_type_id) {
|
||
payload.content_type_id = bestCategory.content_type_id
|
||
}
|
||
if (bestCategory?.root_category_id || bestCategory?.id) {
|
||
payload.category_id = bestCategory.root_category_id || bestCategory.id
|
||
}
|
||
if (Object.keys(payload).length > 0) {
|
||
persistAiAction(payload)
|
||
}
|
||
trackAiEvent('improve_all_applied', {
|
||
applied_title: Boolean(bestTitle),
|
||
applied_description: Boolean(bestDescription),
|
||
applied_tags: aiSuggestedTags.length > 0,
|
||
applied_category: Boolean(bestCategory),
|
||
})
|
||
}, [aiData, aiStatus, aiSuggestedTags, persistAiAction, trackAiEvent, triggerAi])
|
||
|
||
const requestAiIntent = useCallback((intent, action = null) => {
|
||
const nextAction = action || (aiStatus === 'ready' ? 'regenerate' : 'analyze')
|
||
trackAiEvent('intent_requested', { intent, action: nextAction })
|
||
triggerAi(nextAction, { intent })
|
||
}, [aiStatus, trackAiEvent, triggerAi])
|
||
|
||
const toggleAiPanel = useCallback(() => {
|
||
setIsAiPanelOpen((current) => {
|
||
const next = !current
|
||
trackAiEvent('panel_toggled', { open: next })
|
||
return next
|
||
})
|
||
}, [trackAiEvent])
|
||
|
||
useEffect(() => {
|
||
loadAiData()
|
||
}, [loadAiData])
|
||
|
||
useEffect(() => {
|
||
if (aiStatus !== 'queued' && aiStatus !== 'processing') return undefined
|
||
const timer = window.setInterval(() => loadAiData(true), 4000)
|
||
return () => window.clearInterval(timer)
|
||
}, [aiStatus, loadAiData])
|
||
|
||
useEffect(() => {
|
||
if (!hasScheduledRelease) return undefined
|
||
|
||
const timer = window.setInterval(() => {
|
||
setNowMs(Date.now())
|
||
}, 1000)
|
||
|
||
return () => window.clearInterval(timer)
|
||
}, [hasScheduledRelease])
|
||
|
||
useEffect(() => {
|
||
const selectedSlug = String(groupSlug || '')
|
||
if (!selectedSlug) {
|
||
if (primaryAuthorUserId || contributorUserIds.length > 0 || Object.keys(contributorCredits || {}).length > 0) {
|
||
setPrimaryAuthorUserId(null)
|
||
setContributorUserIds([])
|
||
setContributorCredits({})
|
||
}
|
||
return
|
||
}
|
||
|
||
const validGroup = groupOptions.some((group) => String(group.slug || '') === selectedSlug)
|
||
if (!validGroup) {
|
||
setGroupSlug('')
|
||
setPrimaryAuthorUserId(null)
|
||
setContributorUserIds([])
|
||
setContributorCredits({})
|
||
return
|
||
}
|
||
|
||
const validContributorIds = currentContributorOptions.map((user) => Number(user.id)).filter((id) => Number.isFinite(id) && id > 0)
|
||
const nextPrimaryAuthorId = validContributorIds.includes(Number(primaryAuthorUserId))
|
||
? Number(primaryAuthorUserId)
|
||
: (validContributorIds[0] || null)
|
||
|
||
const nextContributorIds = contributorUserIds
|
||
.map((id) => Number(id))
|
||
.filter((id) => validContributorIds.includes(id) && id !== nextPrimaryAuthorId)
|
||
|
||
const nextContributorCredits = normalizeContributorCredits(nextContributorIds, contributorCredits)
|
||
const primaryChanged = (primaryAuthorUserId ? Number(primaryAuthorUserId) : null) !== nextPrimaryAuthorId
|
||
const contributorsChanged = nextContributorIds.length !== contributorUserIds.length || nextContributorIds.some((id, index) => id !== contributorUserIds[index])
|
||
const contributorCreditsChanged = JSON.stringify(nextContributorCredits) !== JSON.stringify(normalizeContributorCredits(contributorUserIds, contributorCredits))
|
||
|
||
if (primaryChanged) setPrimaryAuthorUserId(nextPrimaryAuthorId)
|
||
if (contributorsChanged) setContributorUserIds(nextContributorIds)
|
||
if (contributorCreditsChanged) setContributorCredits(nextContributorCredits)
|
||
}, [groupSlug, groupOptions, currentContributorOptions, primaryAuthorUserId, contributorUserIds, contributorCredits])
|
||
|
||
const handleSave = useCallback(async () => {
|
||
setSaving(true)
|
||
setSaved(false)
|
||
setErrors({})
|
||
try {
|
||
const payload = {
|
||
title,
|
||
description,
|
||
visibility,
|
||
mode: publishMode,
|
||
publish_at: publishMode === 'schedule' ? scheduledAt : null,
|
||
timezone: userTimezone,
|
||
group: groupSlug || null,
|
||
primary_author_user_id: groupSlug ? primaryAuthorUserId : null,
|
||
contributor_user_ids: groupSlug ? contributorUserIds : [],
|
||
contributor_credits: groupSlug
|
||
? contributorUserIds.map((id) => ({
|
||
user_id: id,
|
||
credit_role: contributorCredits?.[id]?.creditRole?.trim() ? contributorCredits[id].creditRole.trim() : null,
|
||
is_primary: Boolean(contributorCredits?.[id]?.isPrimary),
|
||
}))
|
||
: [],
|
||
content_type_id: contentTypeId,
|
||
category_id: selectedLeafCategoryId,
|
||
tags: tagSlugs,
|
||
title_source: titleSource,
|
||
description_source: descriptionSource,
|
||
tags_source: tagsSource,
|
||
category_source: categorySource,
|
||
world_submissions: worldSubmissionOptions
|
||
.filter((world) => Boolean(world?.selected))
|
||
.map((world) => ({
|
||
world_id: Number(world.id),
|
||
note: typeof world.note === 'string' ? world.note : '',
|
||
}))
|
||
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0),
|
||
evolution_target_artwork_id: evolutionTarget?.id || null,
|
||
evolution_relation_type: evolutionTarget ? evolutionRelationType : null,
|
||
evolution_note: evolutionTarget ? evolutionNote : null,
|
||
}
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
const updatedArtwork = data?.artwork || null
|
||
const updatedEvolutionRelation = data?.evolution_relation || updatedArtwork?.evolution_relation || null
|
||
if (updatedArtwork) {
|
||
setVisibility(updatedArtwork.visibility || visibility)
|
||
setPublishMode(updatedArtwork.publish_mode || 'now')
|
||
setScheduledAt(updatedArtwork.publish_at || null)
|
||
setGroupSlug(updatedArtwork.group_slug || '')
|
||
setPrimaryAuthorUserId(updatedArtwork.primary_author_user_id || null)
|
||
setContributorUserIds(Array.isArray(updatedArtwork.contributor_user_ids) ? updatedArtwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])
|
||
setContributorCredits(normalizeContributorCredits(updatedArtwork.contributor_user_ids || [], mapContributorCredits(updatedArtwork.contributor_credits || [])))
|
||
}
|
||
if (Array.isArray(data?.world_submission_options)) {
|
||
setWorldSubmissionOptions(normalizeWorldSubmissionOptions(data.world_submission_options))
|
||
}
|
||
setEvolutionTarget(updatedEvolutionRelation?.target_artwork || null)
|
||
setEvolutionRelationType(updatedEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of')
|
||
setEvolutionNote(updatedEvolutionRelation?.note || '')
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 3000)
|
||
} else {
|
||
const data = await res.json()
|
||
if (data.errors) setErrors(data.errors)
|
||
}
|
||
} catch (err) {
|
||
console.error('Save failed:', err)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
|
||
|
||
const handleFileReplace = async (file) => {
|
||
if (!file) return
|
||
setReplacing(true)
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
if (changeNote.trim()) fd.append('change_note', changeNote.trim())
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, {
|
||
method: 'POST',
|
||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
body: fd,
|
||
})
|
||
const data = await res.json()
|
||
if (res.ok && data.thumb_url) {
|
||
syncMediaPayload(data, { fallbackName: file.name, fallbackSize: file.size })
|
||
if (data.version_number) setVersionCount(data.version_number)
|
||
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
|
||
setChangeNote('')
|
||
setShowChangeNote(false)
|
||
if (pendingReplacePreview) { URL.revokeObjectURL(pendingReplacePreview); setPendingReplacePreview(null) }
|
||
setPendingReplaceFile(null)
|
||
} else {
|
||
alert(data.error || 'File replacement failed.')
|
||
}
|
||
} catch (err) {
|
||
console.error('File replace failed:', err)
|
||
} finally {
|
||
setReplacing(false)
|
||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||
}
|
||
}
|
||
|
||
const loadVersionHistory = async () => {
|
||
setHistoryLoading(true)
|
||
setShowHistory(true)
|
||
try {
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, {
|
||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
})
|
||
setHistoryData(await res.json())
|
||
} catch (err) {
|
||
console.error('Failed to load version history:', err)
|
||
} finally {
|
||
setHistoryLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleRestoreVersion = async (versionId) => {
|
||
if (!window.confirm('Restore this version? A copy will become the new current version.')) return
|
||
setRestoring(versionId)
|
||
try {
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
|
||
method: 'POST',
|
||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
})
|
||
const data = await res.json()
|
||
if (res.ok && data.success) {
|
||
syncMediaPayload(data)
|
||
if (data.version_number) setVersionCount(data.version_number)
|
||
setShowHistory(false)
|
||
} else {
|
||
alert(data.error || 'Restore failed.')
|
||
}
|
||
} catch (err) {
|
||
console.error('Restore failed:', err)
|
||
} finally {
|
||
setRestoring(null)
|
||
}
|
||
}
|
||
|
||
const syncMediaPayload = useCallback((payload, options = {}) => {
|
||
const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : null
|
||
const fallbackSize = Number.isFinite(options.fallbackSize) ? Number(options.fallbackSize) : null
|
||
|
||
if (payload?.thumb_url) {
|
||
setThumbUrl(payload.thumb_url_lg || payload.thumb_url)
|
||
}
|
||
|
||
setSelectedMediaId('cover')
|
||
setFileMeta({
|
||
name: payload?.file_name || fallbackName || '—',
|
||
size: typeof payload?.file_size === 'number' ? payload.file_size : (fallbackSize ?? 0),
|
||
width: payload?.width || 0,
|
||
height: payload?.height || 0,
|
||
})
|
||
|
||
if (typeof payload?.file_ext === 'string') setFileExt(payload.file_ext)
|
||
if (typeof payload?.mime_type === 'string') setMimeType(payload.mime_type)
|
||
if (typeof payload?.has_archive_file !== 'undefined') setHasArchiveFile(Boolean(payload.has_archive_file))
|
||
if (Array.isArray(payload?.screenshots)) setArtworkScreenshots(payload.screenshots)
|
||
}, [])
|
||
|
||
const resetArchiveRevisionState = useCallback(() => {
|
||
setArchiveRevisionError('')
|
||
if (archiveCoverPreview) URL.revokeObjectURL(archiveCoverPreview)
|
||
setArchiveCoverFile(null)
|
||
setArchiveCoverPreview(null)
|
||
setArchivePackageFile(null)
|
||
setArchiveExtraScreenshots([])
|
||
archiveExtraPreviews.forEach((url) => URL.revokeObjectURL(url))
|
||
setArchiveExtraPreviews([])
|
||
Object.values(replaceShotPreviews).forEach((url) => URL.revokeObjectURL(url))
|
||
setReplaceShots({})
|
||
setReplaceShotPreviews({})
|
||
setRemovedShots({})
|
||
}, [archiveCoverPreview, archiveExtraPreviews, replaceShotPreviews])
|
||
|
||
const handleArchiveRevisionSubmit = async () => {
|
||
const hasReplaceShots = Object.values(replaceShots).some(Boolean)
|
||
const hasRemovedShots = Object.values(removedShots).some(Boolean)
|
||
if (!archiveCoverFile && !archivePackageFile && archiveExtraScreenshots.length === 0 && !hasReplaceShots && !hasRemovedShots) {
|
||
setArchiveRevisionError('Choose a new cover screenshot, a new archive file, or extra screenshots first.')
|
||
return
|
||
}
|
||
|
||
setArchiveRevisionSaving(true)
|
||
setArchiveRevisionError('')
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
if (archiveCoverFile) fd.append('cover_file', archiveCoverFile)
|
||
if (archivePackageFile) fd.append('archive_file', archivePackageFile)
|
||
archiveExtraScreenshots.forEach((file) => fd.append('screenshot_files[]', file))
|
||
Object.entries(replaceShots).forEach(([idx, file]) => {
|
||
if (file) fd.append(`replace_shots[${idx}]`, file)
|
||
})
|
||
Object.entries(removedShots).forEach(([idx, removed]) => {
|
||
if (removed) fd.append('remove_shots[]', idx)
|
||
})
|
||
if (changeNote.trim()) fd.append('change_note', changeNote.trim())
|
||
|
||
const res = await fetch(`/api/studio/artworks/${artwork.id}/revise-media`, {
|
||
method: 'POST',
|
||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||
credentials: 'same-origin',
|
||
body: fd,
|
||
})
|
||
|
||
const data = await res.json()
|
||
if (!res.ok || !data.success) {
|
||
setArchiveRevisionError(data.error || 'Archive revision failed.')
|
||
return
|
||
}
|
||
|
||
syncMediaPayload(data)
|
||
if (data.version_number) setVersionCount(data.version_number)
|
||
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
|
||
setShowChangeNote(false)
|
||
setChangeNote('')
|
||
resetArchiveRevisionState()
|
||
} catch (err) {
|
||
console.error('Archive revision failed:', err)
|
||
setArchiveRevisionError('Archive revision failed.')
|
||
} finally {
|
||
setArchiveRevisionSaving(false)
|
||
}
|
||
}
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────────────
|
||
return (
|
||
<StudioLayout title="Edit Artwork">
|
||
|
||
{/* ── Page Header ── */}
|
||
<div className="flex items-center justify-between gap-4 mb-4">
|
||
<div className="flex items-center gap-4 min-w-0">
|
||
<Link
|
||
href="/studio/artworks"
|
||
className="flex items-center justify-center w-9 h-9 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all shrink-0"
|
||
aria-label="Back to artworks"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</Link>
|
||
<div className="min-w-0">
|
||
<h1 className="text-lg font-bold text-white truncate">
|
||
{title || 'Untitled artwork'}
|
||
</h1>
|
||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||
<span>Editing</span>
|
||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||
<span className={publishMode === 'schedule' ? 'text-sky-300' : visibility === 'private' ? 'text-amber-400' : 'text-emerald-400'}>
|
||
{visibilitySummary}
|
||
</span>
|
||
{heroMeta.map((item) => (
|
||
<React.Fragment key={item}>
|
||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||
<span>{item}</span>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Two-column Layout ── */}
|
||
<div className="grid grid-cols-1 gap-6 items-start xl:grid-cols-[300px_minmax(0,1fr)]">
|
||
|
||
{/* ─────────── LEFT SIDEBAR ─────────── */}
|
||
<div className="space-y-4 xl:sticky xl:top-6 xl:max-h-[calc(100vh-48px)] xl:overflow-y-auto xl:overscroll-contain xl:pr-1 nova-scrollbar">
|
||
|
||
{/* Preview Card */}
|
||
<Section className="overflow-hidden">
|
||
<SectionTitle icon="fa-solid fa-image">Media</SectionTitle>
|
||
|
||
<div className="space-y-4">
|
||
<div className="rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.14),_transparent_54%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-3 shadow-[0_20px_60px_rgba(2,8,23,0.28)]">
|
||
<div className="relative overflow-hidden rounded-[22px] border border-white/10 bg-black/25">
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-start justify-between gap-2 p-3">
|
||
<span className="rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/75">
|
||
{archiveArtwork ? 'Archive package' : 'Single image'}
|
||
</span>
|
||
<span className="rounded-full border border-accent/20 bg-accent/12 px-2.5 py-1 text-[10px] font-semibold text-accent">
|
||
v{versionCount}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="relative aspect-[4/5] min-h-[280px]">
|
||
{activeMedia?.url ? (
|
||
<img
|
||
src={activeMedia.url}
|
||
alt={title || 'Artwork preview'}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||
<path d="M21 15l-5-5L5 21" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/50">{activeMediaLabel}</p>
|
||
<p className="mt-1 truncate text-sm font-semibold text-white" title={fileMeta.name}>{fileMeta.name}</p>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-white/65">
|
||
{currentFileExt && (
|
||
<span className="rounded-full border border-white/10 bg-white/10 px-2 py-0.5 uppercase tracking-[0.14em] text-white/70">{currentFileExt}</span>
|
||
)}
|
||
{screenshotItems.length > 0 && (
|
||
<span>{screenshotItems.length} screenshot{screenshotItems.length !== 1 ? 's' : ''}</span>
|
||
)}
|
||
{fileMeta.width > 0 && (
|
||
<span>{fileMeta.width} × {fileMeta.height}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{replacing && (
|
||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60">
|
||
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-black/55 px-4 py-2 text-xs text-white/80 backdrop-blur">
|
||
<div className="h-5 w-5 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
|
||
Uploading new revision…
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] text-slate-300">
|
||
<span className="font-semibold uppercase tracking-[0.16em] text-slate-500">Size</span>
|
||
<span className="font-semibold text-white">{formatBytes(fileMeta.size)}</span>
|
||
</div>
|
||
|
||
{downloadUrl ? (
|
||
<a
|
||
href={downloadUrl}
|
||
aria-label="Download artwork"
|
||
title="Download artwork"
|
||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-emerald-400/25 bg-emerald-400/10 text-emerald-200 transition hover:bg-emerald-400/15 hover:text-white"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||
<path d="M8.75 1.5a.75.75 0 00-1.5 0v6.19L5.53 5.97a.75.75 0 10-1.06 1.06l3 3a.75.75 0 001.06 0l3-3a.75.75 0 10-1.06-1.06L8.75 7.69V1.5z" />
|
||
<path d="M2.5 10.75A.75.75 0 013.25 10h9.5a.75.75 0 010 1.5h-9.5a.75.75 0 01-.75-.75z" />
|
||
<path d="M2 12.5A1.5 1.5 0 013.5 11h9a1.5 1.5 0 011.5 1.5v1A1.5 1.5 0 0112.5 15h-9A1.5 1.5 0 012 13.5v-1z" />
|
||
</svg>
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('media')}
|
||
className="group flex w-full items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/[0.05]"
|
||
>
|
||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] text-slate-300 transition group-hover:border-accent/30 group-hover:bg-accent/10 group-hover:text-accent">
|
||
<i className="fa-solid fa-photo-film text-[13px]" aria-hidden="true" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Media</span>
|
||
<span className="mt-1 block text-sm font-medium text-white">
|
||
{archiveArtwork ? 'Manage package and screenshots' : 'Replace image and manage screenshots'}
|
||
</span>
|
||
</span>
|
||
<span className="pt-0.5 text-slate-600 transition group-hover:text-slate-300" aria-hidden="true">
|
||
<i className="fa-solid fa-chevron-right text-[11px]" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section className="space-y-3">
|
||
<SectionTitle icon="fa-solid fa-layer-group">Publishing Snapshot</SectionTitle>
|
||
|
||
{[
|
||
{
|
||
id: 'taxonomy',
|
||
label: 'Category',
|
||
value: categoryPreviewSummary,
|
||
hint: categorySource === 'manual' ? 'Manual category path' : `Source: ${String(categorySource).replace(/_/g, ' ')}`,
|
||
icon: 'fa-solid fa-palette',
|
||
},
|
||
{
|
||
id: 'visibility',
|
||
label: 'Visibility',
|
||
value: visibilitySummary,
|
||
hint: visibilityPreviewHint,
|
||
icon: 'fa-solid fa-eye',
|
||
},
|
||
hasScheduledRelease
|
||
? {
|
||
id: 'visibility',
|
||
label: 'Scheduler',
|
||
value: schedulePreviewSummary,
|
||
hint: schedulePreviewHint,
|
||
icon: 'fa-regular fa-clock',
|
||
}
|
||
: null,
|
||
].filter(Boolean).map((item) => (
|
||
<button
|
||
key={`${item.label}-${item.id}`}
|
||
type="button"
|
||
onClick={() => setActiveTab(item.id)}
|
||
className="group flex w-full items-start gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/[0.05]"
|
||
>
|
||
<span className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] text-slate-300 transition group-hover:border-accent/30 group-hover:bg-accent/10 group-hover:text-accent">
|
||
<i className={`${item.icon} text-[13px]`} aria-hidden="true" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{item.label}</span>
|
||
<span className="mt-1 block truncate text-sm font-medium text-white" title={item.value}>{item.value}</span>
|
||
<span className="mt-1 block text-xs text-slate-500">{item.hint}</span>
|
||
</span>
|
||
<span className="pt-1 text-slate-600 transition group-hover:text-slate-300" aria-hidden="true">
|
||
<i className="fa-solid fa-chevron-right text-[11px]" />
|
||
</span>
|
||
</button>
|
||
))}
|
||
</Section>
|
||
|
||
{/* Quick Links */}
|
||
<Section className="py-3 px-4">
|
||
<Link
|
||
href={`/studio/artworks/${artwork?.id}/analytics`}
|
||
className="flex items-center gap-3 py-2 text-sm text-slate-400 hover:text-white transition-colors group"
|
||
>
|
||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 group-hover:bg-accent/15 transition-colors">
|
||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="text-slate-500 group-hover:text-accent transition-colors" aria-hidden="true">
|
||
<path d="M1 11a1 1 0 011-1h2a1 1 0 011 1v3a1 1 0 01-1 1H2a1 1 0 01-1-1v-3zm5-4a1 1 0 011-1h2a1 1 0 011 1v7a1 1 0 01-1 1H7a1 1 0 01-1-1V7zm5-5a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V2z" />
|
||
</svg>
|
||
</span>
|
||
View Analytics
|
||
</Link>
|
||
</Section>
|
||
</div>
|
||
|
||
{/* ─────────── RIGHT MAIN FORM ─────────── */}
|
||
<div className="flex flex-col min-h-0">
|
||
|
||
{/* ── Tab Nav ── */}
|
||
<div className="sticky top-0 z-30 bg-nova-900/95 backdrop-blur-md flex items-stretch border-b border-white/10 mb-6">
|
||
<div className="flex items-center overflow-x-auto flex-1 min-w-0">
|
||
{TABS.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={[
|
||
'relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors',
|
||
activeTab === tab.id
|
||
? 'text-white after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-accent after:rounded-t-full'
|
||
: 'text-slate-400 hover:text-slate-200',
|
||
].join(' ')}
|
||
>
|
||
<i className={`${tab.icon} text-[11px]`} aria-hidden="true" />
|
||
{tab.label}
|
||
{tab.id === 'evolution' && evolutionTarget && (
|
||
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" />
|
||
)}
|
||
{tab.id === 'ai' && aiStatus !== 'not_analyzed' && (
|
||
<span className={`h-1.5 w-1.5 rounded-full ${aiStatus === 'ready' ? 'bg-emerald-400' : aiStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400'}`} />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2 px-3 flex-shrink-0">
|
||
{saved && (
|
||
<span className="text-xs text-emerald-400 flex items-center gap-1">
|
||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||
</svg>
|
||
Saved
|
||
</span>
|
||
)}
|
||
<Button variant="accent" size="xs" loading={saving} onClick={handleSave}>
|
||
Save
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Category tab ── */}
|
||
{activeTab === 'taxonomy' && (
|
||
<div className="space-y-6">
|
||
<Section id="taxonomy" className="space-y-6">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<SectionTitle icon="fa-solid fa-palette">Category</SectionTitle>
|
||
<p className="-mt-2 text-sm text-slate-400">Pick a content type from the left, then choose the best category path on the right. The layout keeps the hierarchy visible instead of stretching into one long wall of chips.</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-slate-300">
|
||
<span className="text-slate-500">Type</span>
|
||
<span className="font-semibold text-white">{selectedCT?.name || 'Unset'}</span>
|
||
</span>
|
||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-slate-300">
|
||
<span className="text-slate-500">Path</span>
|
||
<span className="font-semibold text-white">{selectedRoot?.name || 'Choose category'}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||
<div className="rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Content types</h4>
|
||
<p className="mt-1 text-xs text-slate-500">Start here</p>
|
||
</div>
|
||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-slate-400">{contentTypes.length}</span>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-2">
|
||
{contentTypes.map((ct) => {
|
||
const isActive = contentTypeId === ct.id
|
||
const visualKey = getContentTypeVisualKey(ct.slug)
|
||
const categoryCount = ct.rootCategories?.length || 0
|
||
|
||
return (
|
||
<button
|
||
key={ct.id}
|
||
type="button"
|
||
onClick={() => handleContentTypeChange(ct.id)}
|
||
className={[
|
||
'group flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all',
|
||
isActive
|
||
? 'border-emerald-400/40 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.18)]'
|
||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||
].join(' ')}
|
||
>
|
||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${isActive ? 'border-emerald-400/30 bg-emerald-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
|
||
<img
|
||
src={`/gfx/mascot_${visualKey}.webp`}
|
||
alt=""
|
||
className="h-8 w-8 object-contain"
|
||
onError={(e) => { e.target.style.display = 'none' }}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className={`text-sm font-semibold ${isActive ? 'text-emerald-200' : 'text-white'}`}>{ct.name}</div>
|
||
<div className="mt-1 text-[11px] text-slate-500">{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}</div>
|
||
</div>
|
||
<div className={`text-xs ${isActive ? 'text-emerald-300' : 'text-slate-500 group-hover:text-slate-300'}`}>
|
||
{isActive ? 'Selected' : 'Open'}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.08),_rgba(15,23,36,0.92)_52%)] p-4 sm:p-5">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Category path</h4>
|
||
<p className="mt-1 text-sm text-slate-400">Choose the main branch first, then refine with a subcategory when needed.</p>
|
||
</div>
|
||
<InlineAiButton onClick={() => requestAiIntent('category')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'}>
|
||
Category
|
||
</InlineAiButton>
|
||
</div>
|
||
|
||
{!selectedCT && (
|
||
<div className="mt-5 rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center">
|
||
<div className="text-sm font-medium text-white">Select a content type first</div>
|
||
<p className="mt-2 text-sm text-slate-500">Once you choose the content type, the matching category tree will appear here.</p>
|
||
</div>
|
||
)}
|
||
|
||
{selectedCT && (
|
||
<div className="mt-5 space-y-5">
|
||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-200">{selectedCT.name}</span>
|
||
<span>contains {rootCategories.length} top-level {rootCategories.length === 1 ? 'category' : 'categories'}</span>
|
||
</div>
|
||
|
||
{selectedRoot && !isCategoryChooserOpen && (
|
||
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.08] p-4">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-purple-200/80">Selected category</div>
|
||
<div className="mt-1 text-lg font-semibold text-white">{selectedRoot.name}</div>
|
||
<div className="mt-1 text-sm text-slate-400">
|
||
{subCategories.length > 0
|
||
? `Next step: choose one of the ${subCategories.length} subcategories below.`
|
||
: 'This category is complete. No subcategory is required.'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCategoryChooserOpen(true)}
|
||
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
Change
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(!selectedRoot || isCategoryChooserOpen) && (
|
||
<div className="grid gap-3 lg:grid-cols-2">
|
||
{rootCategories.map((cat) => {
|
||
const isActive = categoryId === cat.id
|
||
const childCount = cat.children?.length || 0
|
||
|
||
return (
|
||
<button
|
||
key={cat.id}
|
||
type="button"
|
||
onClick={() => handleCategoryChange(cat.id)}
|
||
className={[
|
||
'rounded-2xl border px-4 py-4 text-left transition-all',
|
||
isActive
|
||
? 'border-purple-400/40 bg-purple-400/12 shadow-[0_0_0_1px_rgba(192,132,252,0.15)]'
|
||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||
].join(' ')}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
|
||
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories available` : 'Standalone category'}</div>
|
||
</div>
|
||
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
|
||
{isActive ? 'Selected' : 'Choose'}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{selectedRoot && subCategories.length > 0 && (
|
||
<div className="rounded-2xl border border-cyan-400/15 bg-cyan-400/[0.05] p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h5 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Subcategories</h5>
|
||
<p className="mt-1 text-sm text-slate-400">Refine <span className="text-white">{selectedRoot.name}</span> with one more level.</p>
|
||
</div>
|
||
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-1 text-[11px] text-cyan-200">{subCategories.length}</span>
|
||
</div>
|
||
|
||
{!subCategoryId && (
|
||
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
|
||
Subcategory still needs to be selected.
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2.5">
|
||
{subCategories.map((sub) => {
|
||
const isActive = subCategoryId === sub.id
|
||
return (
|
||
<button
|
||
key={sub.id}
|
||
type="button"
|
||
onClick={() => handleSubCategoryChange(sub.id)}
|
||
className={[
|
||
'rounded-xl border px-3 py-2 text-xs font-medium transition-all',
|
||
isActive
|
||
? 'border-cyan-400/40 bg-cyan-400/15 text-cyan-200'
|
||
: 'border-white/10 bg-white/[0.04] text-slate-300 hover:border-white/20 hover:text-white',
|
||
].join(' ')}
|
||
>
|
||
{sub.name}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedRoot && subCategories.length === 0 && (
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
|
||
<span className="text-white font-medium">{selectedRoot.name}</span> does not have subcategories. Selecting it is enough.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{errors.category_id && <p className="mt-4 text-xs text-red-400">{errors.category_id[0]}</p>}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Details tab ── */}
|
||
{activeTab === 'details' && (
|
||
<Section id="details" className="space-y-5">
|
||
<SectionTitle icon="fa-solid fa-pen-fancy">Details</SectionTitle>
|
||
|
||
<TextInput
|
||
label={<FieldLabel label={<span className="inline-flex items-center gap-1">Title <span className="text-red-400">*</span></span>} actionLabel="Title" onAction={() => requestAiIntent('title')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />}
|
||
value={title}
|
||
onChange={handleTitleChange}
|
||
placeholder="Give your artwork a title"
|
||
error={errors.title?.[0]}
|
||
/>
|
||
|
||
<FormField label={<FieldLabel label="Description" actionLabel="Description" onAction={() => requestAiIntent('description')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />} htmlFor="artwork-description">
|
||
<RichTextEditor
|
||
content={description}
|
||
onChange={handleDescriptionChange}
|
||
placeholder="Describe your artwork, tools, inspiration…"
|
||
error={errors.description?.[0]}
|
||
minHeight={12}
|
||
autofocus={false}
|
||
/>
|
||
</FormField>
|
||
|
||
<Section className="space-y-5 border-white/8 bg-white/[0.02]">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<SectionTitle icon="fa-solid fa-users">Attribution</SectionTitle>
|
||
<p className="-mt-2 text-sm text-slate-400">Switch between personal and group context, then maintain primary author and contributor credits without leaving the edit screen.</p>
|
||
</div>
|
||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${groupSlug ? 'border-sky-300/20 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300'}`}>
|
||
{selectedGroupOption ? `Group: ${selectedGroupOption.name}` : 'Personal publish'}
|
||
</span>
|
||
</div>
|
||
|
||
<NovaSelect
|
||
label="Publishing identity"
|
||
value={groupSlug || ''}
|
||
onChange={(nextValue) => setGroupSlug(String(nextValue || ''))}
|
||
options={publishingIdentityOptions}
|
||
searchable={false}
|
||
placeholder="Choose publishing identity"
|
||
error={errors.group?.[0]}
|
||
hint={selectedGroupOption
|
||
? 'The artwork will be publicly published under the selected group while authorship stays editable below.'
|
||
: 'Personal publishing keeps the artwork under your own creator profile.'}
|
||
className="mt-2 bg-black/20"
|
||
renderOption={(option) => (
|
||
<span className="flex min-w-0 items-center gap-3">
|
||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04]">
|
||
{option.icon}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate font-medium text-white">{option.label}</span>
|
||
<span className="block truncate text-[11px] text-slate-500">{option.contextLabel}</span>
|
||
</span>
|
||
</span>
|
||
)}
|
||
/>
|
||
|
||
{groupSlug ? (
|
||
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||
<div>
|
||
<NovaSelect
|
||
label="Primary author"
|
||
value={primaryAuthorUserId || null}
|
||
onChange={(nextValue) => setPrimaryAuthorUserId(nextValue ? Number(nextValue) : null)}
|
||
options={primaryAuthorOptions}
|
||
placeholder="Choose primary author"
|
||
searchable={primaryAuthorOptions.length > 6}
|
||
error={errors.primary_author_user_id?.[0]}
|
||
hint="Primary author remains the lead creator shown on the public artwork page."
|
||
className="mt-2 bg-black/20"
|
||
renderOption={(option) => (
|
||
<span className="flex min-w-0 items-center gap-3">
|
||
{option.avatarUrl ? (
|
||
<img src={option.avatarUrl} alt="" className="h-7 w-7 shrink-0 rounded-full object-cover" />
|
||
) : (
|
||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-slate-400">
|
||
<i className="fa-solid fa-user text-[11px]" aria-hidden="true" />
|
||
</span>
|
||
)}
|
||
<span className="min-w-0">
|
||
<span className="block truncate font-medium text-white">{option.label}</span>
|
||
{option.username ? <span className="block truncate text-[11px] text-slate-500">@{option.username}</span> : null}
|
||
</span>
|
||
</span>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-sm font-medium text-white/90">Contributors</span>
|
||
<span className="text-xs text-slate-500">Optional</span>
|
||
</div>
|
||
<div className="mt-2 grid gap-2">
|
||
{currentContributorOptions.filter((user) => Number(user.id) !== Number(primaryAuthorUserId)).map((user) => {
|
||
const active = contributorUserIds.some((id) => Number(id) === Number(user.id))
|
||
const creditMeta = contributorCredits?.[user.id] || contributorCredits?.[String(user.id)] || { creditRole: '', isPrimary: false }
|
||
|
||
return (
|
||
<div
|
||
key={user.id}
|
||
className={[
|
||
'rounded-2xl border px-3 py-3 transition',
|
||
active
|
||
? 'border-sky-300/30 bg-sky-300/10 text-white'
|
||
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06]',
|
||
].join(' ')}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{user.avatar_url ? <img src={user.avatar_url} alt={user.name || user.username} className="h-10 w-10 rounded-2xl object-cover" /> : <div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-semibold">{user.name || user.username}</div>
|
||
<div className="truncate text-xs text-slate-400">@{user.username}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setContributorUserIds((current) => {
|
||
const nextIds = new Set(current.map((id) => Number(id)).filter((id) => id !== Number(primaryAuthorUserId)))
|
||
if (nextIds.has(Number(user.id))) {
|
||
nextIds.delete(Number(user.id))
|
||
} else {
|
||
nextIds.add(Number(user.id))
|
||
}
|
||
|
||
const normalizedIds = Array.from(nextIds)
|
||
setContributorCredits((currentCredits) => normalizeContributorCredits(normalizedIds, currentCredits))
|
||
return normalizedIds
|
||
})
|
||
}}
|
||
className={[
|
||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition',
|
||
active
|
||
? 'border-sky-300/40 bg-sky-300/20 text-sky-50'
|
||
: 'border-white/10 bg-white/[0.03] text-white/70 hover:border-white/20 hover:text-white',
|
||
].join(' ')}
|
||
>
|
||
<span className={['inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px]', active ? 'border-sky-300/40 bg-sky-300/20 text-sky-50' : 'border-white/10 bg-white/[0.03] text-white/35'].join(' ')}>{active ? '✓' : ''}</span>
|
||
{active ? 'Added' : 'Add credit'}
|
||
</button>
|
||
</div>
|
||
|
||
{active ? (
|
||
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||
<label className="block">
|
||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-slate-300">Credit role</span>
|
||
<input
|
||
type="text"
|
||
value={creditMeta.creditRole || ''}
|
||
onChange={(event) => setContributorCredits((current) => ({
|
||
...normalizeContributorCredits(contributorUserIds, current),
|
||
[Number(user.id)]: {
|
||
...(normalizeContributorCredits(contributorUserIds, current)[Number(user.id)] || { isPrimary: false }),
|
||
creditRole: event.target.value,
|
||
},
|
||
}))}
|
||
placeholder="Colorist, concept support, layout..."
|
||
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setContributorCredits((current) => {
|
||
const nextCredits = normalizeContributorCredits(contributorUserIds, current)
|
||
contributorUserIds.forEach((id) => {
|
||
nextCredits[id] = {
|
||
...(nextCredits[id] || { creditRole: '' }),
|
||
isPrimary: id === Number(user.id),
|
||
}
|
||
})
|
||
return nextCredits
|
||
})}
|
||
className={[
|
||
'inline-flex items-center justify-center rounded-xl border px-3 py-3 text-sm font-medium transition',
|
||
creditMeta.isPrimary
|
||
? 'border-emerald-300/35 bg-emerald-400/12 text-emerald-100'
|
||
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06] hover:text-white',
|
||
].join(' ')}
|
||
>
|
||
{creditMeta.isPrimary ? 'Lead support' : 'Set lead support'}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{errors.contributor_credits?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.contributor_credits[0]}</p> : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
|
||
Personal publishing uses your own creator profile as the primary author automatically.
|
||
</div>
|
||
)}
|
||
</Section>
|
||
</Section>
|
||
)}
|
||
|
||
{/* ── Media tab ── */}
|
||
{activeTab === 'media' && (
|
||
<Section id="media" className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<SectionTitle icon="fa-solid fa-photo-film">Media & Revisions</SectionTitle>
|
||
<p className="-mt-2 text-sm text-slate-400">
|
||
{quickReplaceSupported
|
||
? 'Drop or upload a new image, then add or manage up to 4 extra screenshots in the same media workspace.'
|
||
: 'Replace the archive package, update the cover screenshot, or add / replace screenshots — saved together as one revision.'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={loadVersionHistory}
|
||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-slate-300 transition hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||
<path fillRule="evenodd" d="M8 3.5a4.5 4.5 0 00-4.04 2.51.75.75 0 01-1.34-.67A6 6 0 1114 8a.75.75 0 01-1.5 0A4.5 4.5 0 008 3.5z" clipRule="evenodd" />
|
||
<path fillRule="evenodd" d="M4.75.75a.75.75 0 00-.75.75v3.5c0 .414.336.75.75.75h3.5a.75.75 0 000-1.5H5.5V1.5a.75.75 0 00-.75-.75z" clipRule="evenodd" />
|
||
</svg>
|
||
History
|
||
</button>
|
||
</div>
|
||
|
||
{/* Current file summary bar */}
|
||
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||
{thumbUrl && <img src={thumbUrl} alt="" className="h-full w-full object-cover" />}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-semibold text-white" title={fileMeta.name}>{fileMeta.name || 'Artwork file'}</p>
|
||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||
{currentFileExt && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-white/55">{currentFileExt}</span>}
|
||
{fileMeta.width > 0 && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/55">{fileMeta.width} × {fileMeta.height}</span>}
|
||
{fileMeta.size > 0 && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/55">{formatBytes(fileMeta.size)}</span>}
|
||
{screenshotItems.length > 0 && <span className="rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/55">{screenshotItems.length} screenshot{screenshotItems.length !== 1 ? 's' : ''}</span>}
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-400">v{versionCount}</span>
|
||
</div>
|
||
|
||
{requiresReapproval && (
|
||
<div className="rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||
Visual changes on this artwork require a new moderation pass.
|
||
</div>
|
||
)}
|
||
|
||
{/* ════ Single image replace ════ */}
|
||
{quickReplaceSupported && (() => {
|
||
const zone = 'single-replace'
|
||
const isDragging = dragOverZone === zone
|
||
const stageFile = (file) => {
|
||
if (!file || !file.type.startsWith('image/')) return
|
||
if (pendingReplacePreview) URL.revokeObjectURL(pendingReplacePreview)
|
||
setPendingReplaceFile(file)
|
||
setPendingReplacePreview(URL.createObjectURL(file))
|
||
}
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Replace image</p>
|
||
|
||
{/* Drop zone */}
|
||
<div
|
||
className={[
|
||
'relative overflow-hidden rounded-2xl border-2 border-dashed transition',
|
||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||
].join(' ')}
|
||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragLeave={() => setDragOverZone(null)}
|
||
onDrop={(e) => {
|
||
e.preventDefault()
|
||
setDragOverZone(null)
|
||
stageFile(e.dataTransfer.files?.[0])
|
||
}}
|
||
>
|
||
{pendingReplacePreview ? (
|
||
<div className="flex items-start gap-4 p-4">
|
||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||
<img src={pendingReplacePreview} alt="Preview" className="h-full w-full object-cover" />
|
||
</div>
|
||
<div className="flex-1 min-w-0 py-1">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-400">Ready to upload</p>
|
||
<p className="mt-1 truncate text-sm font-medium text-white">{pendingReplaceFile?.name}</p>
|
||
<p className="mt-0.5 text-xs text-slate-400">{formatBytes(pendingReplaceFile?.size)}</p>
|
||
<button
|
||
type="button"
|
||
className="mt-3 text-xs text-slate-400 hover:text-white transition-colors"
|
||
onClick={() => { URL.revokeObjectURL(pendingReplacePreview); setPendingReplacePreview(null); setPendingReplaceFile(null) }}
|
||
>
|
||
✕ Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-3 p-10 text-center">
|
||
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-slate-400">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||
</svg>
|
||
</span>
|
||
<span className="text-sm font-medium text-slate-300">Drop image here or <span className="text-sky-300">browse</span></span>
|
||
<span className="text-xs text-slate-500">JPG · PNG · WEBP · TIFF — any resolution</span>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
className="hidden"
|
||
accept="image/*"
|
||
onChange={(e) => stageFile(e.target.files?.[0])}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{replacing && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-2xl bg-black/65 backdrop-blur-sm">
|
||
<div className="h-8 w-8 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
|
||
<span className="text-sm text-slate-300">Uploading revision…</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Change note + Upload button */}
|
||
<TextInput
|
||
value={changeNote}
|
||
onChange={(e) => setChangeNote(e.target.value)}
|
||
placeholder="Change note for this revision… (optional)"
|
||
size="sm"
|
||
/>
|
||
|
||
{pendingReplaceFile && (
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="accent"
|
||
size="sm"
|
||
loading={replacing}
|
||
onClick={() => handleFileReplace(pendingReplaceFile)}
|
||
>
|
||
{replacing ? 'Uploading…' : 'Upload new version'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* ════ Screenshot and archive media form ════ */}
|
||
<div className="space-y-6">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">{archiveArtwork ? 'Revise archive media' : 'Additional screenshots'}</p>
|
||
|
||
{archiveRevisionError && (
|
||
<div className="rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||
{archiveRevisionError}
|
||
</div>
|
||
)}
|
||
|
||
{archiveArtwork && (
|
||
<>
|
||
{/* ── Cover screenshot ── */}
|
||
<div className="space-y-2">
|
||
<p className="text-xs font-semibold text-slate-400">Cover screenshot</p>
|
||
{(() => {
|
||
const zone = 'archive-cover'
|
||
const isDragging = dragOverZone === zone
|
||
const stageFile = (file) => {
|
||
if (!file || !file.type.startsWith('image/')) return
|
||
if (archiveCoverPreview) URL.revokeObjectURL(archiveCoverPreview)
|
||
setArchiveCoverFile(file)
|
||
setArchiveCoverPreview(URL.createObjectURL(file))
|
||
}
|
||
return (
|
||
<div
|
||
className={[
|
||
'relative overflow-hidden rounded-2xl border-2 border-dashed transition',
|
||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||
].join(' ')}
|
||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragLeave={() => setDragOverZone(null)}
|
||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageFile(e.dataTransfer.files?.[0]) }}
|
||
>
|
||
<div className="flex items-start gap-4 p-4">
|
||
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||
{archiveCoverPreview
|
||
? <img src={archiveCoverPreview} alt="New cover" className="h-full w-full object-cover" />
|
||
: thumbUrl
|
||
? <img src={thumbUrl} alt="Current cover" className="h-full w-full object-cover opacity-60" />
|
||
: null}
|
||
</div>
|
||
<div className="flex-1 min-w-0 py-1">
|
||
{archiveCoverFile ? (
|
||
<>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-400">New cover staged</p>
|
||
<p className="mt-1 truncate text-sm font-medium text-white">{archiveCoverFile.name}</p>
|
||
<p className="mt-0.5 text-xs text-slate-400">{formatBytes(archiveCoverFile.size)}</p>
|
||
<button type="button" className="mt-2 text-xs text-slate-400 hover:text-white transition-colors"
|
||
onClick={() => { URL.revokeObjectURL(archiveCoverPreview); setArchiveCoverFile(null); setArchiveCoverPreview(null) }}>
|
||
✕ Remove
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-xs font-semibold text-slate-500">Current cover</p>
|
||
<p className="mt-1 text-xs text-slate-400">Drop a new image or click to browse.</p>
|
||
<label className="mt-2 inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-300 transition hover:bg-white/[0.08] hover:text-white">
|
||
<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.75 14A1.75 1.75 0 011 12.25V9.5a.75.75 0 011.5 0v2.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V9.5a.75.75 0 011.5 0v2.75A1.75 1.75 0 0113.25 14H2.75z"/><path d="M11.78 5.22a.75.75 0 00-1.06 0L8.75 7.19V1.75a.75.75 0 00-1.5 0v5.44L5.28 5.22a.75.75 0 00-1.06 1.06l3.25 3.25a.75.75 0 001.06 0l3.25-3.25a.75.75 0 000-1.06z"/></svg>
|
||
Replace cover
|
||
<input type="file" className="hidden" accept="image/*" onChange={(e) => stageFile(e.target.files?.[0])} />
|
||
</label>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* ── Archive package ── */}
|
||
<div className="space-y-2">
|
||
<p className="text-xs font-semibold text-slate-400">Archive package</p>
|
||
{(() => {
|
||
const zone = 'archive-pkg'
|
||
const isDragging = dragOverZone === zone
|
||
const stageFile = (file) => {
|
||
if (!file) return
|
||
setArchivePackageFile(file)
|
||
}
|
||
return (
|
||
<div
|
||
className={[
|
||
'rounded-2xl border-2 border-dashed transition',
|
||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||
].join(' ')}
|
||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragLeave={() => setDragOverZone(null)}
|
||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageFile(e.dataTransfer.files?.[0]) }}
|
||
>
|
||
<div className="flex items-center gap-4 px-4 py-4">
|
||
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] text-slate-400">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||
</svg>
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
{archivePackageFile ? (
|
||
<>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-400">New package staged</p>
|
||
<p className="mt-0.5 truncate text-sm font-medium text-white">{archivePackageFile.name}</p>
|
||
<p className="mt-0.5 text-xs text-slate-400">{formatBytes(archivePackageFile.size)}</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-xs font-semibold text-slate-500">Current package</p>
|
||
<p className="mt-0.5 truncate text-sm text-slate-300">{fileMeta.name || '—'}</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 flex-col gap-2">
|
||
{archivePackageFile ? (
|
||
<button type="button" className="text-xs text-slate-400 hover:text-white transition-colors"
|
||
onClick={() => setArchivePackageFile(null)}>
|
||
✕ Remove
|
||
</button>
|
||
) : (
|
||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-300 transition hover:bg-white/[0.08] hover:text-white">
|
||
<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.75 14A1.75 1.75 0 011 12.25V9.5a.75.75 0 011.5 0v2.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V9.5a.75.75 0 011.5 0v2.75A1.75 1.75 0 0113.25 14H2.75z"/><path d="M11.78 5.22a.75.75 0 00-1.06 0L8.75 7.19V1.75a.75.75 0 00-1.5 0v5.44L5.28 5.22a.75.75 0 00-1.06 1.06l3.25 3.25a.75.75 0 001.06 0l3.25-3.25a.75.75 0 000-1.06z"/></svg>
|
||
Replace
|
||
<input type="file" className="hidden"
|
||
accept=".zip,.rar,.7z,.tar,.gz,application/zip,application/x-zip-compressed,application/x-rar-compressed,application/vnd.rar,application/x-7z-compressed,application/x-tar,application/gzip"
|
||
onChange={(e) => stageFile(e.target.files?.[0])} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Existing screenshots ── */}
|
||
{screenshotItems.length > 0 && (
|
||
<div className="space-y-3">
|
||
<p className="text-xs font-semibold text-slate-400">Screenshots <span className="ml-1 font-normal text-slate-500">({activeScreenshotCount} active / {screenshotItems.length} existing)</span></p>
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
{screenshotItems.map((shot, i) => {
|
||
const shotZone = `replace-shot-${i}`
|
||
const isDragging = dragOverZone === shotZone
|
||
const pendingPreview = replaceShotPreviews[i]
|
||
const pendingFile = replaceShots[i]
|
||
const isRemoved = Boolean(removedShots[i])
|
||
const stageShot = (file) => {
|
||
if (!file || !file.type.startsWith('image/')) return
|
||
if (pendingPreview) URL.revokeObjectURL(pendingPreview)
|
||
setRemovedShots((prev) => {
|
||
const next = { ...prev }
|
||
delete next[i]
|
||
return next
|
||
})
|
||
setReplaceShots((prev) => ({ ...prev, [i]: file }))
|
||
setReplaceShotPreviews((prev) => ({ ...prev, [i]: URL.createObjectURL(file) }))
|
||
}
|
||
return (
|
||
<div
|
||
key={shot.id || `shot-${i}`}
|
||
className={[
|
||
'relative overflow-hidden rounded-2xl border-2 border-dashed transition',
|
||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/10 bg-white/[0.02]',
|
||
].join(' ')}
|
||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(shotZone) }}
|
||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(shotZone) }}
|
||
onDragLeave={() => setDragOverZone(null)}
|
||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageShot(e.dataTransfer.files?.[0]) }}
|
||
>
|
||
<div className="aspect-square overflow-hidden bg-black/25">
|
||
<img
|
||
src={pendingPreview || shot.thumb_url || shot.url}
|
||
alt={shot.label || `Screenshot ${i + 1}`}
|
||
className={[
|
||
'h-full w-full object-cover transition-opacity',
|
||
isRemoved ? 'opacity-25 grayscale' : 'opacity-100',
|
||
].join(' ')}
|
||
/>
|
||
{pendingPreview && (
|
||
<div className="absolute inset-x-0 top-0 flex items-center justify-center gap-1 bg-emerald-500/80 py-1 text-[10px] font-semibold text-white">
|
||
New
|
||
</div>
|
||
)}
|
||
{isRemoved && (
|
||
<div className="absolute inset-x-0 top-0 flex items-center justify-center gap-1 bg-red-500/85 py-1 text-[10px] font-semibold text-white">
|
||
Disabled for next save
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="space-y-1 p-2">
|
||
<div className="flex items-center justify-between gap-1">
|
||
<span className="text-[10px] text-slate-500">Shot {i + 1}</span>
|
||
{pendingFile ? (
|
||
<button type="button" className="text-[10px] text-slate-400 hover:text-white transition-colors"
|
||
onClick={() => {
|
||
URL.revokeObjectURL(pendingPreview)
|
||
setReplaceShots((prev) => { const n = { ...prev }; delete n[i]; return n })
|
||
setReplaceShotPreviews((prev) => { const n = { ...prev }; delete n[i]; return n })
|
||
}}>
|
||
Undo replace
|
||
</button>
|
||
) : isRemoved ? (
|
||
<button
|
||
type="button"
|
||
className="text-[10px] text-emerald-300 hover:text-white transition-colors"
|
||
onClick={() => setRemovedShots((prev) => { const next = { ...prev }; delete next[i]; return next })}
|
||
>
|
||
Re-enable
|
||
</button>
|
||
) : (
|
||
<label className="cursor-pointer text-[10px] font-semibold text-sky-300 hover:text-white transition-colors">
|
||
Replace
|
||
<input type="file" className="hidden" accept="image/*"
|
||
onChange={(e) => stageShot(e.target.files?.[0])} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="truncate text-[10px] text-slate-600">{pendingFile ? pendingFile.name : (shot.label || `Screenshot ${i + 1}`)}</span>
|
||
{!pendingFile && !isRemoved && (
|
||
<button
|
||
type="button"
|
||
className="text-[10px] text-red-300 hover:text-white transition-colors"
|
||
onClick={() => setRemovedShots((prev) => ({ ...prev, [i]: true }))}
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Add new screenshots ── */}
|
||
{(activeScreenshotCount < 4) && (
|
||
<div className="space-y-3">
|
||
<p className="text-xs font-semibold text-slate-400">Add screenshots <span className="ml-1 font-normal text-slate-500">(up to {4 - activeScreenshotCount} more)</span></p>
|
||
{(() => {
|
||
const zone = 'archive-extra-shots'
|
||
const isDragging = dragOverZone === zone
|
||
const availableSlots = Math.max(0, 4 - activeScreenshotCount)
|
||
const stageFiles = (files) => {
|
||
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/')).slice(0, availableSlots)
|
||
archiveExtraPreviews.forEach((url) => URL.revokeObjectURL(url))
|
||
setArchiveExtraScreenshots(imageFiles)
|
||
setArchiveExtraPreviews(imageFiles.map((f) => URL.createObjectURL(f)))
|
||
}
|
||
return (
|
||
<>
|
||
<div
|
||
className={[
|
||
'rounded-2xl border-2 border-dashed transition',
|
||
isDragging ? 'border-sky-400/60 bg-sky-400/10' : 'border-white/15 bg-white/[0.02]',
|
||
].join(' ')}
|
||
onDragOver={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragEnter={(e) => { e.preventDefault(); setDragOverZone(zone) }}
|
||
onDragLeave={() => setDragOverZone(null)}
|
||
onDrop={(e) => { e.preventDefault(); setDragOverZone(null); stageFiles(e.dataTransfer.files) }}
|
||
>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 py-6 text-center">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-slate-500" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||
</svg>
|
||
<span className="text-sm font-medium text-slate-300">Drop images here or <span className="text-sky-300">browse</span></span>
|
||
<span className="text-xs text-slate-500">Select up to {availableSlots} image{availableSlots !== 1 ? 's' : ''}</span>
|
||
<input type="file" className="hidden" accept="image/*" multiple
|
||
onChange={(e) => stageFiles(e.target.files)} />
|
||
</label>
|
||
</div>
|
||
|
||
{archiveExtraPreviews.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{archiveExtraPreviews.map((url, i) => (
|
||
<div key={url} className="relative overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||
<div className="aspect-square">
|
||
<img src={url} alt={`New ${i + 1}`} className="h-full w-full object-cover" />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="Remove"
|
||
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/70 text-[10px] text-white hover:bg-red-500/80 transition-colors"
|
||
onClick={() => {
|
||
URL.revokeObjectURL(url)
|
||
const next = archiveExtraScreenshots.filter((_, j) => j !== i)
|
||
const nextPrev = archiveExtraPreviews.filter((_, j) => j !== i)
|
||
setArchiveExtraScreenshots(next)
|
||
setArchiveExtraPreviews(nextPrev)
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Change note + save */}
|
||
<TextInput
|
||
value={changeNote}
|
||
onChange={(e) => setChangeNote(e.target.value)}
|
||
placeholder={archiveArtwork ? 'Describe what changed in this revision… (optional)' : 'Describe the screenshot update… (optional)'}
|
||
size="sm"
|
||
/>
|
||
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="accent"
|
||
size="sm"
|
||
loading={archiveRevisionSaving}
|
||
onClick={handleArchiveRevisionSubmit}
|
||
>
|
||
{archiveRevisionSaving ? 'Saving revision…' : 'Save revision'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* ── Evolution tab ── */}
|
||
{activeTab === 'evolution' && (
|
||
<Section id="evolution" className="space-y-6">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<SectionTitle icon="fa-solid fa-code-branch">Artwork Evolution</SectionTitle>
|
||
<p className="-mt-2 text-sm text-slate-400">Connect this piece to an older public original so the artwork page can tell a clear Then & Now story in both directions.</p>
|
||
</div>
|
||
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] ${evolutionTarget ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300'}`}>
|
||
<i className="fa-solid fa-sparkles text-[10px]" aria-hidden="true" />
|
||
{evolutionTarget ? (selectedEvolutionType?.short_label || 'Linked') : 'No original linked'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||
<div className="space-y-4">
|
||
<ArtworkEvolutionSearchPicker
|
||
artworkId={artwork?.id}
|
||
selected={evolutionTarget}
|
||
onSelect={(option) => setEvolutionTarget(option)}
|
||
disabled={saving}
|
||
/>
|
||
{errors.evolution_target_artwork_id?.[0] ? <p className="text-sm text-red-400">{errors.evolution_target_artwork_id[0]}</p> : null}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<RightRailCard title="Relation settings">
|
||
<div className="space-y-4">
|
||
<label className="block">
|
||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Story type</span>
|
||
<select
|
||
value={evolutionRelationType}
|
||
onChange={(event) => setEvolutionRelationType(event.target.value)}
|
||
disabled={saving || !evolutionTarget}
|
||
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{evolutionRelationTypes.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{errors.evolution_relation_type?.[0] ? <p className="text-sm text-red-400">{errors.evolution_relation_type[0]}</p> : null}
|
||
|
||
<label className="block">
|
||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Creator note</span>
|
||
<textarea
|
||
value={evolutionNote}
|
||
onChange={(event) => setEvolutionNote(event.target.value)}
|
||
placeholder="Optional context for viewers: what changed, what you learned, why this version matters..."
|
||
disabled={saving || !evolutionTarget}
|
||
rows={6}
|
||
className="mt-2 w-full rounded-[24px] border border-white/10 bg-[#0d1726] px-4 py-3 text-sm leading-6 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||
/>
|
||
</label>
|
||
{errors.evolution_note?.[0] ? <p className="text-sm text-red-400">{errors.evolution_note[0]}</p> : null}
|
||
</div>
|
||
</RightRailCard>
|
||
|
||
<RightRailCard title="Public behavior">
|
||
<div className="space-y-3 text-sm leading-relaxed text-slate-300">
|
||
<p>The newer artwork gets the main comparison panel. The older artwork gets an updated-version card pointing back here.</p>
|
||
<p>Only published public artworks can be linked, and the original has to be older than the piece you are editing.</p>
|
||
<p>{evolutionTarget ? `Current target: ${evolutionTarget.title}` : 'Pick an older artwork first to unlock relation type and note settings.'}</p>
|
||
</div>
|
||
</RightRailCard>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* ── AI Assist tab ── */}
|
||
{activeTab === 'ai' && (
|
||
<Section id="ai-assist" className="space-y-5">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<SectionTitle icon="fa-solid fa-wand-magic-sparkles">AI Assist</SectionTitle>
|
||
<p className="text-sm text-slate-400">Review-only suggestions built from the current artwork image. Nothing is written to the artwork until you apply it.</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 self-start">
|
||
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold ${statusTone(aiStatus)}`}>
|
||
<span className={`h-2 w-2 rounded-full ${aiStatus === 'ready' ? 'bg-emerald-300' : aiStatus === 'failed' ? 'bg-red-300' : aiStatus === 'queued' || aiStatus === 'processing' ? 'bg-sky-300' : 'bg-slate-400'}`} />
|
||
<span>{statusLabel(aiStatus)}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={toggleAiPanel}
|
||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
<span>{isAiPanelOpen ? 'Collapse' : 'Expand'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isAiPanelOpen && (
|
||
<>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="secondary" size="xs" onClick={() => requestAiIntent('analyze', 'analyze')} loading={aiAction === 'analyze'}>
|
||
{aiDirect ? 'Analyze now' : 'Analyze artwork'}
|
||
</Button>
|
||
<Button variant="secondary" size="xs" onClick={handleImproveAll}>
|
||
Improve all
|
||
</Button>
|
||
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('title')}>
|
||
Generate title
|
||
</Button>
|
||
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('description')}>
|
||
Generate description
|
||
</Button>
|
||
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('tags')}>
|
||
Suggest tags
|
||
</Button>
|
||
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('category')}>
|
||
Suggest category
|
||
</Button>
|
||
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('similar')}>
|
||
Find similar
|
||
</Button>
|
||
<Button variant="ghost" size="xs" onClick={() => requestAiIntent('analyze', 'regenerate')} loading={aiAction === 'regenerate'}>
|
||
Refresh suggestions
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<Toggle
|
||
checked={aiDirect}
|
||
onChange={(event) => setAiDirect(event.target.checked)}
|
||
label="Run AI directly"
|
||
hint="Optional. When enabled, AI analysis runs inline and returns suggestions immediately instead of going through the queue."
|
||
size="sm"
|
||
variant="sky"
|
||
disabled={aiAction !== ''}
|
||
/>
|
||
</div>
|
||
|
||
{aiLoading && (
|
||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||
<div className="h-4 w-4 rounded-full border-2 border-sky-400/20 border-t-sky-300 animate-spin" />
|
||
<span>Loading AI assist data…</span>
|
||
</div>
|
||
)}
|
||
|
||
{aiData?.error_message && (
|
||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 px-4 py-3 text-sm text-red-100">
|
||
{aiData.error_message}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h4 className="text-sm font-semibold text-white">Title suggestions</h4>
|
||
<button type="button" onClick={() => requestAiIntent('title', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate</button>
|
||
</div>
|
||
{(aiData?.title_suggestions || []).length === 0 && <p className="text-sm text-slate-500">Analyze the artwork to generate title ideas.</p>}
|
||
{(aiData?.title_suggestions || []).map((item) => (
|
||
<div key={item.text} className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-medium text-white">{item.text}</div>
|
||
{typeof item.confidence === 'number' && <div className="text-[11px] text-slate-500">Confidence {Math.round(item.confidence * 100)}%</div>}
|
||
</div>
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
<button type="button" onClick={() => applyTitleSuggestion(item.text, 'replace')} className="text-xs text-sky-200 transition hover:text-white">Replace</button>
|
||
<button type="button" onClick={() => applyTitleSuggestion(item.text, 'insert')} className="text-xs text-slate-300 transition hover:text-white">Insert</button>
|
||
<button type="button" onClick={() => copyText(item.text)} className="text-xs text-slate-400 transition hover:text-white">Copy</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h4 className="text-sm font-semibold text-white">Description suggestions</h4>
|
||
<button type="button" onClick={() => requestAiIntent('description', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate</button>
|
||
</div>
|
||
{(aiData?.description_suggestions || []).length === 0 && <p className="text-sm text-slate-500">AI descriptions appear here after analysis.</p>}
|
||
{(aiData?.description_suggestions || []).map((item) => (
|
||
<div key={`${item.variant}-${item.text}`} className="rounded-xl border border-white/10 bg-white/[0.04] p-3 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{item.variant}</div>
|
||
{typeof item.confidence === 'number' && <div className="text-[11px] text-slate-500">{Math.round(item.confidence * 100)}%</div>}
|
||
</div>
|
||
<p className="text-sm leading-6 text-slate-200">{item.text}</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => applyDescriptionSuggestion(item.text, 'replace')} className="text-xs text-sky-200 transition hover:text-white">Replace</button>
|
||
<button type="button" onClick={() => applyDescriptionSuggestion(item.text, 'append')} className="text-xs text-slate-300 transition hover:text-white">Append</button>
|
||
<button type="button" onClick={() => copyText(item.text)} className="text-xs text-slate-400 transition hover:text-white">Copy</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h4 className="text-sm font-semibold text-white">Tag suggestions</h4>
|
||
<button type="button" onClick={() => requestAiIntent('tags', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate</button>
|
||
</div>
|
||
{(aiData?.tag_suggestions || []).length === 0 && <p className="text-sm text-slate-500">Suggested tags, confidence, and quick-apply actions appear here.</p>}
|
||
{(aiData?.tag_suggestions || []).length > 0 && (
|
||
<>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(aiData?.tag_suggestions || []).map((item) => {
|
||
const isApplied = tagSlugs.includes(item.tag)
|
||
const isSelected = selectedAiTags.includes(item.tag)
|
||
return (
|
||
<button
|
||
key={item.tag}
|
||
type="button"
|
||
onClick={() => toggleSuggestedTag(item.tag)}
|
||
className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${isSelected ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200' : isApplied ? 'border-white/20 bg-white/[0.08] text-white' : 'border-sky-400/20 bg-sky-400/10 text-sky-200 hover:bg-sky-400/15'}`}
|
||
>
|
||
{isSelected ? '✓' : isApplied ? '•' : '+'} {item.tag}
|
||
{typeof item.confidence === 'number' && <span className="ml-1 text-[10px] text-white/60">{Math.round(item.confidence * 100)}%</span>}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => applyTagSuggestions(selectedAiTags, 'add')} className="text-xs text-sky-200 transition hover:text-white">Add selected</button>
|
||
<button type="button" onClick={() => applyTagSuggestions(aiSuggestedTags, 'add')} className="text-xs text-slate-300 transition hover:text-white">Add all</button>
|
||
<button type="button" onClick={() => applyTagSuggestions(aiSuggestedTags, 'remove')} className="text-xs text-slate-400 transition hover:text-white">Remove suggested</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h4 className="text-sm font-semibold text-white">Category suggestions</h4>
|
||
<button type="button" onClick={() => requestAiIntent('category', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate</button>
|
||
</div>
|
||
{!aiData?.content_type && !aiData?.category && <p className="text-sm text-slate-500">AI content-type and category candidates appear here after analysis.</p>}
|
||
{aiData?.content_type && (
|
||
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Suggested content type</div>
|
||
<div className="mt-1 text-sm font-medium text-white">{aiData.content_type.label}</div>
|
||
<div className="mt-2">
|
||
<button type="button" onClick={() => applyCategorySuggestion(aiData.category || { content_type_id: aiData.content_type.id }, 'content_type')} className="text-xs text-sky-200 transition hover:text-white">Apply content type</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{aiData?.category && (
|
||
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3 space-y-3">
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Suggested category</div>
|
||
<div className="mt-1 text-sm font-medium text-white">{aiData.category.label}</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button type="button" onClick={() => applyCategorySuggestion(aiData.category, 'category')} className="text-xs text-sky-200 transition hover:text-white">Apply category</button>
|
||
<button type="button" onClick={() => applyCategorySuggestion(aiData.category, 'both')} className="text-xs text-slate-300 transition hover:text-white">Apply both</button>
|
||
</div>
|
||
{(aiData.category.alternatives || []).length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{aiData.category.alternatives.map((item) => (
|
||
<button key={item.id} type="button" onClick={() => applyCategorySuggestion(item, 'both')} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-slate-300 transition hover:text-white">
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h4 className="text-sm font-semibold text-white">Similar / duplicate candidates</h4>
|
||
<button type="button" onClick={() => requestAiIntent('similar', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Refresh</button>
|
||
</div>
|
||
{(aiData?.similar_candidates || []).length === 0 && <p className="text-sm text-slate-500">Possible duplicates or visually similar artworks will appear here.</p>}
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{(aiData?.similar_candidates || []).map((item) => (
|
||
<div key={item.artwork_id} className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
|
||
<div className="aspect-[4/3] overflow-hidden rounded-lg bg-white/5">
|
||
{item.thumbnail_url ? (
|
||
<img src={item.thumbnail_url} alt={item.title || 'Similar artwork'} className="h-full w-full object-cover" />
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-xs text-slate-500">No preview</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 space-y-1">
|
||
<div className="text-sm font-medium text-white">{item.title || `Artwork #${item.artwork_id}`}</div>
|
||
<div className="text-[11px] text-slate-500">#{item.artwork_id} · {item.match_type} · {typeof item.score === 'number' ? `${Math.round(item.score * 100)}%` : 'n/a'}</div>
|
||
{item.owner && <div className="text-[11px] text-slate-500">{item.owner}</div>}
|
||
{item.review_state && <div className="text-[11px] text-emerald-300 uppercase tracking-[0.18em]">{item.review_state}</div>}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{item.url && <a href={item.url} target="_blank" rel="noreferrer" onClick={() => trackAiEvent('duplicate_candidate_viewed', { candidate_artwork_id: item.artwork_id, match_type: item.match_type })} className="text-xs text-sky-200 transition hover:text-white">Open</a>}
|
||
<button type="button" onClick={() => persistAiAction({ similar_actions: [{ artwork_id: item.artwork_id, state: 'ignored' }] })} className="text-xs text-slate-300 transition hover:text-white">Ignore</button>
|
||
<button type="button" onClick={() => persistAiAction({ similar_actions: [{ artwork_id: item.artwork_id, state: 'reviewed' }] })} className="text-xs text-slate-400 transition hover:text-white">Mark as reviewed</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Section>
|
||
)}
|
||
|
||
{/* ── Tags tab ── */}
|
||
{activeTab === 'tags' && (
|
||
<Section id="tags" className="space-y-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<SectionTitle icon="fa-solid fa-tags">Tags</SectionTitle>
|
||
<InlineAiButton onClick={() => requestAiIntent('tags')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'}>
|
||
Tags
|
||
</InlineAiButton>
|
||
</div>
|
||
<TagPicker
|
||
value={tagSlugs}
|
||
onChange={handleTagChange}
|
||
suggestedTags={aiSuggestedTags}
|
||
searchEndpoint="/api/studio/tags/search"
|
||
popularEndpoint="/api/studio/tags/search"
|
||
error={errors.tags?.[0]}
|
||
/>
|
||
</Section>
|
||
)}
|
||
|
||
{activeTab === 'worlds' && (
|
||
<WorldSubmissionSelector
|
||
title="Add to Worlds"
|
||
description="Attach this artwork to active worlds for creator participation. These remain separate from moderator-curated world relations and keep their own review state."
|
||
options={worldSubmissionOptions}
|
||
emptyMessage="No worlds are currently open for creator participation, and this artwork has no existing world history yet."
|
||
onToggle={(worldId) => setWorldSubmissionOptions((current) => current.map((world) => (
|
||
Number(world.id) === Number(worldId) && !world.selection_locked
|
||
? { ...world, selected: !world.selected }
|
||
: world
|
||
)))}
|
||
onNoteChange={(worldId, note) => setWorldSubmissionOptions((current) => current.map((world) => (
|
||
Number(world.id) === Number(worldId) ? { ...world, note } : world
|
||
)))}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Visibility tab ── */}
|
||
{activeTab === 'visibility' && (
|
||
<Section id="visibility" className="space-y-5">
|
||
<div className="space-y-1">
|
||
<SectionTitle icon="fa-solid fa-eye">Visibility</SectionTitle>
|
||
<p className="text-xs text-slate-500 -mt-2">
|
||
Match the same publish options used during upload, including unlisted access and scheduled publishing.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
{[
|
||
{ value: 'public', label: 'Public', hint: 'Visible to everyone' },
|
||
{ value: 'unlisted', label: 'Unlisted', hint: 'Available by direct link' },
|
||
{ value: 'private', label: 'Private', hint: 'Keep as draft visibility' },
|
||
].map((option) => {
|
||
const active = visibility === option.value
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
onClick={() => setVisibility(option.value)}
|
||
className={[
|
||
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-4 text-left transition',
|
||
active
|
||
? 'border-sky-300/30 bg-sky-400/10 text-white'
|
||
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
|
||
].join(' ')}
|
||
>
|
||
<div>
|
||
<div className="text-sm font-semibold">{option.label}</div>
|
||
<div className="mt-1 text-xs text-white/50">{option.hint}</div>
|
||
</div>
|
||
<span className={[
|
||
'mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]',
|
||
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/35',
|
||
].join(' ')}>
|
||
{active ? '✓' : ''}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||
<SchedulePublishPicker
|
||
mode={publishMode}
|
||
scheduledAt={scheduledAt}
|
||
timezone={userTimezone}
|
||
onModeChange={setPublishMode}
|
||
onScheduleAt={setScheduledAt}
|
||
disabled={saving}
|
||
/>
|
||
{errors.publish_at?.[0] && (
|
||
<p className="mt-2 text-xs text-red-400">{errors.publish_at[0]}</p>
|
||
)}
|
||
{errors.visibility?.[0] && (
|
||
<p className="mt-2 text-xs text-red-400">{errors.visibility[0]}</p>
|
||
)}
|
||
<p className="mt-3 text-xs text-slate-500">
|
||
{publishMode === 'schedule'
|
||
? 'Scheduled artworks stay hidden until the selected time.'
|
||
: visibility === 'private'
|
||
? 'Private keeps the artwork hidden from public views.'
|
||
: visibility === 'unlisted'
|
||
? 'Unlisted keeps the artwork accessible but not broadly surfaced.'
|
||
: 'Public makes the artwork visible immediately.'}
|
||
</p>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
{/* ── Version History Modal ── */}
|
||
<Modal
|
||
open={showHistory}
|
||
onClose={() => setShowHistory(false)}
|
||
title="Version History"
|
||
size="lg"
|
||
footer={
|
||
<p className="text-xs text-slate-500 mr-auto">
|
||
Restoring creates a new version — nothing is deleted.
|
||
</p>
|
||
}
|
||
>
|
||
{historyLoading && (
|
||
<div className="flex items-center justify-center py-12">
|
||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
{!historyLoading && historyData && (
|
||
<div className="space-y-3">
|
||
{historyData.versions.map((v) => (
|
||
<div
|
||
key={v.id}
|
||
className={[
|
||
'rounded-xl border p-4 transition-all',
|
||
v.is_current
|
||
? 'border-accent/40 bg-accent/10'
|
||
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]',
|
||
].join(' ')}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-xs font-bold text-white">v{v.version_number}</span>
|
||
{v.is_current && (
|
||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">
|
||
Current
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-400">
|
||
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
|
||
</p>
|
||
{v.width && (
|
||
<p className="text-[11px] text-slate-400">
|
||
{v.width} × {v.height} px · {formatBytes(v.file_size)}
|
||
</p>
|
||
)}
|
||
{v.change_note && (
|
||
<p className="text-xs text-slate-300 mt-1 italic">“{v.change_note}”</p>
|
||
)}
|
||
</div>
|
||
{!v.is_current && (
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
loading={restoring === v.id}
|
||
onClick={() => handleRestoreVersion(v.id)}
|
||
>
|
||
Restore
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{historyData.versions.length === 0 && (
|
||
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
</StudioLayout>
|
||
)
|
||
}
|