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

1490 lines
73 KiB
JavaScript

import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TextInput from '../../components/ui/TextInput'
import Button from '../../components/ui/Button'
import Modal from '../../components/ui/Modal'
import FormField from '../../components/ui/FormField'
import 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 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 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 (
<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">
{/* Preview Card */}
<Section>
<SectionTitle icon="fa-solid fa-image">Preview</SectionTitle>
{/* Thumbnail */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/5 border border-white/10 mb-4">
{thumbUrl ? (
<img
src={thumbUrl}
alt={title || 'Artwork preview'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex 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>
)}
{replacing && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<div className="w-7 h-7 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
</div>
{/* File Metadata */}
<div className="space-y-3">
<p className="text-sm font-medium text-white truncate" title={fileMeta.name}>{fileMeta.name}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
{fileMeta.width > 0 && (
<span className="flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
<path d="M2 3a1 1 0 011-1h10a1 1 0 011 1v10a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm2 1v8h8V4H4z" />
</svg>
{fileMeta.width} &times; {fileMeta.height}
</span>
)}
<span className="flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
<path d="M4 1.5a.5.5 0 00-1 0V3H1.5a.5.5 0 000 1h11a.5.5 0 000-1H11V1.5a.5.5 0 00-1 0V3H6V1.5a.5.5 0 00-1 0V3H4V1.5z" />
<path d="M1.5 5v8.5A1.5 1.5 0 003 15h10a1.5 1.5 0 001.5-1.5V5h-13z" />
</svg>
{formatBytes(fileMeta.size)}
</span>
</div>
{/* Version + History */}
<div className="flex items-center gap-2 pt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-accent bg-accent/15 px-2 py-0.5 rounded-full border border-accent/20">
v{versionCount}
</span>
<button
type="button"
onClick={loadVersionHistory}
className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-accent transition-colors"
>
<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>
{requiresReapproval && (
<p className="text-[11px] text-amber-400/90 flex items-center gap-1.5 mt-1">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8.982 1.566a1.13 1.13 0 00-1.964 0L.165 13.233c-.457.778.091 1.767.982 1.767h13.706c.891 0 1.439-.989.982-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 01-1.1 0L7.1 5.995A.905.905 0 018 5zm.002 6a1 1 0 100 2 1 1 0 000-2z" />
</svg>
Requires re-approval after replace
</p>
)}
</div>
{/* Replace File */}
<div className="mt-4 pt-4 border-t border-white/8 space-y-2.5">
{showChangeNote && (
<TextInput
value={changeNote}
onChange={(e) => setChangeNote(e.target.value)}
placeholder="Change note (optional)…"
size="sm"
/>
)}
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="xs"
loading={replacing}
onClick={() => fileInputRef.current?.click()}
>
{replacing ? 'Replacing…' : 'Replace file'}
</Button>
{!showChangeNote && (
<button
type="button"
onClick={() => setShowChangeNote(true)}
className="text-[11px] text-slate-500 hover:text-white transition-colors"
>
+ note
</button>
)}
</div>
<input ref={fileInputRef} type="file" className="hidden" accept="image/*" onChange={handleFileReplace} />
</div>
</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 === '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="Title" 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]}
required
/>
<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>
)}
{/* ── 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>
)
}