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 TagPicker from '../../components/tags/TagPicker'
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
const EDIT_SECTIONS = [
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
{ id: 'details', label: 'Details', hint: 'Title and description' },
{ 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: '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 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'
}
}
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Glass-morphism section card (Nova theme) */
function Section({ children, className = '', id = undefined }) {
return (
)
}
/** Section heading */
function SectionTitle({ icon, children }) {
return (
{icon && }
{children}
)
}
function InlineAiButton({ children, onClick, disabled = false, loading = false }) {
return (
{loading ? '...' : '✦'}
{children}
)
}
function FieldLabel({ label, actionLabel, onAction, disabled = false, loading = false }) {
return (
{label}
{actionLabel}
)
}
function RightRailCard({ title, children, className = '' }) {
return (
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function StudioArtworkEdit() {
const { props } = usePage()
const { artwork, contentTypes: rawContentTypes } = props
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 [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 [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 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 [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)
// ── 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 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 heroMeta = [
selectedCT?.name || 'No content type',
selectedRoot?.name || 'No root category',
subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null,
].filter(Boolean)
// ── 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])
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,
content_type_id: contentTypeId,
category_id: selectedLeafCategoryId,
tags: tagSlugs,
title_source: titleSource,
description_source: descriptionSource,
tags_source: tagsSource,
category_source: categorySource,
}
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
if (updatedArtwork) {
setVisibility(updatedArtwork.visibility || visibility)
setPublishMode(updatedArtwork.publish_mode || 'now')
setScheduledAt(updatedArtwork.publish_at || null)
}
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, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id])
const handleFileReplace = async (e) => {
const file = e.target.files?.[0]
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) {
setThumbUrl(data.thumb_url)
setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 })
if (data.version_number) setVersionCount(data.version_number)
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
setChangeNote('')
setShowChangeNote(false)
} 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) {
setVersionCount((n) => n + 1)
setShowHistory(false)
} else {
alert(data.error || 'Restore failed.')
}
} catch (err) {
console.error('Restore failed:', err)
} finally {
setRestoring(null)
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
{/* ── Page Header ── */}
{title || 'Untitled artwork'}
Editing
{visibilitySummary}
{heroMeta.map((item) => (
{item}
))}
{/* ── Two-column Layout ── */}
{/* ─────────── LEFT SIDEBAR ─────────── */}
{/* Preview Card */}
Preview
{/* Thumbnail */}
{thumbUrl ? (
) : (
)}
{replacing && (
)}
{/* File Metadata */}
{fileMeta.name}
{fileMeta.width > 0 && (
{fileMeta.width} × {fileMeta.height}
)}
{formatBytes(fileMeta.size)}
{/* Version + History */}
{requiresReapproval && (
Requires re-approval after replace
)}
{/* Replace File */}
{/* Quick Links */}
{/* ─────────── RIGHT MAIN FORM ─────────── */}
{/* ── Tab Nav ── */}
{TABS.map((tab) => (
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(' ')}
>
{tab.label}
{tab.id === 'ai' && aiStatus !== 'not_analyzed' && (
)}
))}
{saved && (
Saved
)}
Save
{/* ── Category tab ── */}
{activeTab === 'taxonomy' && (
Category
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.
Type
{selectedCT?.name || 'Unset'}
Path
{selectedRoot?.name || 'Choose category'}
{contentTypes.map((ct) => {
const isActive = contentTypeId === ct.id
const visualKey = getContentTypeVisualKey(ct.slug)
const categoryCount = ct.rootCategories?.length || 0
return (
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(' ')}
>
{ e.target.style.display = 'none' }}
/>
{ct.name}
{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}
{isActive ? 'Selected' : 'Open'}
)
})}
Category path
Choose the main branch first, then refine with a subcategory when needed.
requestAiIntent('category')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'}>
Category
{!selectedCT && (
Select a content type first
Once you choose the content type, the matching category tree will appear here.
)}
{selectedCT && (
{selectedCT.name}
contains {rootCategories.length} top-level {rootCategories.length === 1 ? 'category' : 'categories'}
{selectedRoot && !isCategoryChooserOpen && (
Selected category
{selectedRoot.name}
{subCategories.length > 0
? `Next step: choose one of the ${subCategories.length} subcategories below.`
: 'This category is complete. No subcategory is required.'}
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
)}
{(!selectedRoot || isCategoryChooserOpen) && (
{rootCategories.map((cat) => {
const isActive = categoryId === cat.id
const childCount = cat.children?.length || 0
return (
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(' ')}
>
{cat.name}
{childCount > 0 ? `${childCount} subcategories available` : 'Standalone category'}
{isActive ? 'Selected' : 'Choose'}
)
})}
)}
{selectedRoot && subCategories.length > 0 && (
Subcategories
Refine {selectedRoot.name} with one more level.
{subCategories.length}
{!subCategoryId && (
Subcategory still needs to be selected.
)}
{subCategories.map((sub) => {
const isActive = subCategoryId === sub.id
return (
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}
)
})}
)}
{selectedRoot && subCategories.length === 0 && (
{selectedRoot.name} does not have subcategories. Selecting it is enough.
)}
)}
{errors.category_id &&
{errors.category_id[0]}
}
)}
{/* ── Details tab ── */}
{activeTab === 'details' && (
Details
requestAiIntent('title')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />}
value={title}
onChange={handleTitleChange}
placeholder="Give your artwork a title"
error={errors.title?.[0]}
required
/>
requestAiIntent('description')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />} htmlFor="artwork-description">
)}
{/* ── AI Assist tab ── */}
{activeTab === 'ai' && (
AI Assist
Review-only suggestions built from the current artwork image. Nothing is written to the artwork until you apply it.
{statusLabel(aiStatus)}
{isAiPanelOpen ? 'Collapse' : 'Expand'}
{isAiPanelOpen && (
<>
requestAiIntent('analyze', 'analyze')} loading={aiAction === 'analyze'}>
{aiDirect ? 'Analyze now' : 'Analyze artwork'}
Improve all
requestAiIntent('title')}>
Generate title
requestAiIntent('description')}>
Generate description
requestAiIntent('tags')}>
Suggest tags
requestAiIntent('category')}>
Suggest category
requestAiIntent('similar')}>
Find similar
requestAiIntent('analyze', 'regenerate')} loading={aiAction === 'regenerate'}>
Refresh suggestions
setIsAiDebugOpen((current) => !current)}>
{isAiDebugOpen ? 'Hide debug' : 'Show debug'}
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 !== ''}
/>
{aiLoading && (
)}
{aiData?.error_message && (
{aiData.error_message}
)}
{isAiDebugOpen && (
AI debug
Inspect the editor request, the outbound vision POST payload, and the raw analysis returned to the suggestion builder.
copyText(JSON.stringify(aiDebugPayload, null, 2))} className="text-xs text-slate-300 transition hover:text-white">Copy JSON
Editor request
{JSON.stringify(lastAiRequest, null, 2)}
Vision request + response
{JSON.stringify(aiData?.debug?.vision_debug || null, null, 2)}
Raw analysis used for suggestions
{JSON.stringify(aiData?.debug?.analysis || null, null, 2)}
)}
Title suggestions
requestAiIntent('title', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate
{(aiData?.title_suggestions || []).length === 0 &&
Analyze the artwork to generate title ideas.
}
{(aiData?.title_suggestions || []).map((item) => (
{item.text}
{typeof item.confidence === 'number' &&
Confidence {Math.round(item.confidence * 100)}%
}
applyTitleSuggestion(item.text, 'replace')} className="text-xs text-sky-200 transition hover:text-white">Replace
applyTitleSuggestion(item.text, 'insert')} className="text-xs text-slate-300 transition hover:text-white">Insert
copyText(item.text)} className="text-xs text-slate-400 transition hover:text-white">Copy
))}
Description suggestions
requestAiIntent('description', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate
{(aiData?.description_suggestions || []).length === 0 &&
AI descriptions appear here after analysis.
}
{(aiData?.description_suggestions || []).map((item) => (
{item.variant}
{typeof item.confidence === 'number' &&
{Math.round(item.confidence * 100)}%
}
{item.text}
applyDescriptionSuggestion(item.text, 'replace')} className="text-xs text-sky-200 transition hover:text-white">Replace
applyDescriptionSuggestion(item.text, 'append')} className="text-xs text-slate-300 transition hover:text-white">Append
copyText(item.text)} className="text-xs text-slate-400 transition hover:text-white">Copy
))}
Tag suggestions
requestAiIntent('tags', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate
{(aiData?.tag_suggestions || []).length === 0 &&
Suggested tags, confidence, and quick-apply actions appear here.
}
{(aiData?.tag_suggestions || []).length > 0 && (
<>
{(aiData?.tag_suggestions || []).map((item) => {
const isApplied = tagSlugs.includes(item.tag)
const isSelected = selectedAiTags.includes(item.tag)
return (
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' && {Math.round(item.confidence * 100)}% }
)
})}
applyTagSuggestions(selectedAiTags, 'add')} className="text-xs text-sky-200 transition hover:text-white">Add selected
applyTagSuggestions(aiSuggestedTags, 'add')} className="text-xs text-slate-300 transition hover:text-white">Add all
applyTagSuggestions(aiSuggestedTags, 'remove')} className="text-xs text-slate-400 transition hover:text-white">Remove suggested
>
)}
Category suggestions
requestAiIntent('category', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Regenerate
{!aiData?.content_type && !aiData?.category &&
AI content-type and category candidates appear here after analysis.
}
{aiData?.content_type && (
Suggested content type
{aiData.content_type.label}
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
)}
{aiData?.category && (
Suggested category
{aiData.category.label}
applyCategorySuggestion(aiData.category, 'category')} className="text-xs text-sky-200 transition hover:text-white">Apply category
applyCategorySuggestion(aiData.category, 'both')} className="text-xs text-slate-300 transition hover:text-white">Apply both
{(aiData.category.alternatives || []).length > 0 && (
{aiData.category.alternatives.map((item) => (
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}
))}
)}
)}
Similar / duplicate candidates
requestAiIntent('similar', 'regenerate')} className="text-xs text-slate-400 transition hover:text-white">Refresh
{(aiData?.similar_candidates || []).length === 0 &&
Possible duplicates or visually similar artworks will appear here.
}
{(aiData?.similar_candidates || []).map((item) => (
{item.thumbnail_url ? (
) : (
No preview
)}
{item.title || `Artwork #${item.artwork_id}`}
#{item.artwork_id} · {item.match_type} · {typeof item.score === 'number' ? `${Math.round(item.score * 100)}%` : 'n/a'}
{item.owner &&
{item.owner}
}
{item.review_state &&
{item.review_state}
}
))}
>
)}
)}
{/* ── Tags tab ── */}
{activeTab === 'tags' && (
)}
{/* ── Visibility tab ── */}
{activeTab === 'visibility' && (
Visibility
Match the same publish options used during upload, including unlisted access and scheduled publishing.
{[
{ 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 (
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(' ')}
>
{option.label}
{option.hint}
{active ? '✓' : ''}
)
})}
{errors.publish_at?.[0] && (
{errors.publish_at[0]}
)}
{errors.visibility?.[0] && (
{errors.visibility[0]}
)}
{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.'}
)}
{/* ── Version History Modal ── */}
setShowHistory(false)}
title="Version History"
size="lg"
footer={
Restoring creates a new version — nothing is deleted.
}
>
{historyLoading && (
)}
{!historyLoading && historyData && (
{historyData.versions.map((v) => (
v{v.version_number}
{v.is_current && (
Current
)}
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
{v.width && (
{v.width} × {v.height} px · {formatBytes(v.file_size)}
)}
{v.change_note && (
“{v.change_note}”
)}
{!v.is_current && (
handleRestoreVersion(v.id)}
>
Restore
)}
))}
{historyData.versions.length === 0 && (
No version history yet.
)}
)}
)
}