1490 lines
73 KiB
JavaScript
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} × {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} × {v.height} px · {formatBytes(v.file_size)}
|
|
</p>
|
|
)}
|
|
{v.change_note && (
|
|
<p className="text-xs text-slate-300 mt-1 italic">“{v.change_note}”</p>
|
|
)}
|
|
</div>
|
|
{!v.is_current && (
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
loading={restoring === v.id}
|
|
onClick={() => handleRestoreVersion(v.id)}
|
|
>
|
|
Restore
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{historyData.versions.length === 0 && (
|
|
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</StudioLayout>
|
|
)
|
|
}
|