Files
SkinbaseNova/resources/js/Pages/Studio/StudioArtworkEdit.jsx

2643 lines
141 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { 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'
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: '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: '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
}, {})
}
// ─── 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 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 [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 [isAiDebugOpen, setIsAiDebugOpen] = useState(false)
const [lastAiRequest, setLastAiRequest] = useState(null)
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 }
setLastAiRequest({
endpoint: `/api/studio/artworks/${artwork.id}/ai/${action}`,
method: 'POST',
body: requestBody,
at: new Date().toISOString(),
})
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 {
setLastAiRequest({
endpoint: `/api/studio/artworks/${artwork.id}/ai/apply`,
method: 'POST',
body: payload,
at: new Date().toISOString(),
})
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 aiDebugPayload = useMemo(() => ({
last_editor_request: lastAiRequest,
stored_debug: aiData?.debug || null,
}), [aiData?.debug, lastAiRequest])
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,
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 || [])))
}
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, 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 &amp; 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>
<Button variant="ghost" size="xs" onClick={() => setIsAiDebugOpen((current) => !current)}>
{isAiDebugOpen ? 'Hide debug' : 'Show debug'}
</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>
)}
{isAiDebugOpen && (
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/[0.06] p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-white">AI debug</h4>
<p className="mt-1 text-xs text-slate-400">Inspect the editor request, the outbound vision POST payload, and the raw analysis returned to the suggestion builder.</p>
</div>
<button type="button" onClick={() => copyText(JSON.stringify(aiDebugPayload, null, 2))} className="text-xs text-slate-300 transition hover:text-white">Copy JSON</button>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Editor request</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(lastAiRequest, null, 2)}</pre>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Vision request + response</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(aiData?.debug?.vision_debug || null, null, 2)}</pre>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Raw analysis used for suggestions</div>
<pre className="mt-3 overflow-x-auto text-xs leading-6 text-slate-300 whitespace-pre-wrap">{JSON.stringify(aiData?.debug?.analysis || null, null, 2)}</pre>
</div>
</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>
)}
{/* ── 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} &times; {v.height} px &middot; {formatBytes(v.file_size)}
</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</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>
)
}