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 (
{children}
) } /** Section heading */ function SectionTitle({ icon, children }) { return (

{icon && } {children}

) } function InlineAiButton({ children, onClick, disabled = false, loading = false }) { return ( ) } function FieldLabel({ label, actionLabel, onAction, disabled = false, loading = false }) { return (
{label} {actionLabel}
) } function RightRailCard({ title, children, className = '' }) { return (

{title}

{children}
) } // ─── Main Component ────────────────────────────────────────────────────────── export default function StudioArtworkEdit() { const { props } = usePage() const { artwork, contentTypes: rawContentTypes } = props const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes]) // ── State ────────────────────────────────────────────────────────────────── const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null) const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null) const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null) const [title, setTitle] = useState(artwork?.title || '') const [description, setDescription] = useState(artwork?.description || '') const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name)) const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private')) const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now')) const [scheduledAt, setScheduledAt] = useState(artwork?.publish_at || null) const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual') const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual') const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual') const [categorySource, setCategorySource] = useState(artwork?.category_source || 'manual') const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [errors, setErrors] = useState({}) const [aiData, setAiData] = useState(null) const [aiLoading, setAiLoading] = useState(false) const [aiAction, setAiAction] = useState('') const [aiDirect, setAiDirect] = useState(false) const [isAiPanelOpen, setIsAiPanelOpen] = useState(true) const [isAiDebugOpen, setIsAiDebugOpen] = useState(false) const [lastAiRequest, setLastAiRequest] = useState(null) const [selectedAiTags, setSelectedAiTags] = useState([]) const [activeTab, setActiveTab] = useState('details') const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !artwork?.parent_category_id) const userTimezone = useMemo(() => artwork?.artwork_timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [artwork?.artwork_timezone]) // File replace const fileInputRef = useRef(null) const [replacing, setReplacing] = useState(false) const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null) const [fileMeta, setFileMeta] = useState({ name: artwork?.file_name || '—', size: artwork?.file_size || 0, width: artwork?.width || 0, height: artwork?.height || 0, }) const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1) const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false) const [changeNote, setChangeNote] = useState('') const [showChangeNote, setShowChangeNote] = useState(false) // Version history const [showHistory, setShowHistory] = useState(false) const [historyData, setHistoryData] = useState(null) const [historyLoading, setHistoryLoading] = useState(false) const [restoring, setRestoring] = useState(null) // ── Derived ──────────────────────────────────────────────────────────────── const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null const rootCategories = selectedCT?.rootCategories || [] const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null const subCategories = selectedRoot?.children || [] const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed' const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData]) const selectedLeafCategoryId = subCategoryId || categoryId || null const visibilitySummary = publishMode === 'schedule' ? `Scheduled as ${visibilityLabel(visibility)}` : visibilityLabel(visibility) const heroMeta = [ selectedCT?.name || 'No content type', selectedRoot?.name || 'No root category', subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null, ].filter(Boolean) // ── Handlers ─────────────────────────────────────────────────────────────── const handleContentTypeChange = (id) => { setContentTypeId(id) setCategoryId(null) setSubCategoryId(null) setIsCategoryChooserOpen(true) setCategorySource((current) => nextSourceForManualEdit(current)) } const handleCategoryChange = (id) => { setCategoryId(id) setSubCategoryId(null) setIsCategoryChooserOpen(false) setCategorySource((current) => nextSourceForManualEdit(current)) } const handleSubCategoryChange = (id) => { setSubCategoryId(id) setCategorySource((current) => nextSourceForManualEdit(current)) } const handleTitleChange = (e) => { setTitle(e.target.value) setTitleSource((current) => nextSourceForManualEdit(current)) } const handleDescriptionChange = (value) => { setDescription(value) setDescriptionSource((current) => nextSourceForManualEdit(current)) } const handleTagChange = (nextTags) => { setTagSlugs(nextTags) setTagsSource((current) => nextSourceForManualEdit(current)) } const loadAiData = useCallback(async (silent = false) => { if (!artwork?.id) return if (!silent) setAiLoading(true) try { const res = await fetch(`/api/studio/artworks/${artwork.id}/ai`, { headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', }) if (!res.ok) return const data = await res.json() setAiData(data.data || null) setSelectedAiTags((data.data?.tag_suggestions || []).map((item) => item.tag).filter(Boolean)) } catch (err) { console.error('AI assist load failed:', err) } finally { if (!silent) setAiLoading(false) } }, [artwork?.id]) const syncCurrentPayload = useCallback((current) => { if (!current) return setTitle(current.title || '') setDescription(current.description || '') setTagSlugs(Array.isArray(current.tags) ? current.tags : []) setContentTypeId(current.content_type_id || null) setCategoryId(current.category_id || null) setSubCategoryId(null) setTitleSource(current.sources?.title || 'manual') setDescriptionSource(current.sources?.description || 'manual') setTagsSource(current.sources?.tags || 'manual') setCategorySource(current.sources?.category || 'manual') }, []) const trackAiEvent = useCallback(async (eventType, meta = {}) => { if (!artwork?.id) return try { await fetch(`/api/studio/artworks/${artwork.id}/ai/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', body: JSON.stringify({ event_type: eventType, meta }), }) } catch (err) { console.error('AI event track failed:', err) } }, [artwork?.id]) const triggerAi = useCallback(async (action = 'analyze', options = {}) => { if (!artwork?.id) return setAiAction(action) try { const direct = typeof options.direct === 'boolean' ? options.direct : aiDirect const intent = options.intent || 'analyze' const requestBody = { direct, intent } setLastAiRequest({ endpoint: `/api/studio/artworks/${artwork.id}/ai/${action}`, method: 'POST', body: requestBody, at: new Date().toISOString(), }) const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', body: JSON.stringify(requestBody), }) if (res.ok) { const data = await res.json() if (direct && data?.data) { setAiData(data.data) } else { await loadAiData(true) } } } catch (err) { console.error('AI assist request failed:', err) } finally { setAiAction('') } }, [aiDirect, artwork?.id, loadAiData]) const persistAiAction = useCallback(async (payload) => { if (!artwork?.id) return setAiAction('apply') try { setLastAiRequest({ endpoint: `/api/studio/artworks/${artwork.id}/ai/apply`, method: 'POST', body: payload, at: new Date().toISOString(), }) const res = await fetch(`/api/studio/artworks/${artwork.id}/ai/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', body: JSON.stringify(payload), }) if (res.ok) { const data = await res.json() if (data?.data) { setAiData(data.data) syncCurrentPayload(data.data.current) setSelectedAiTags((data.data.tag_suggestions || []).map((item) => item.tag).filter(Boolean)) } else { await loadAiData(true) } } } catch (err) { console.error('AI assist apply failed:', err) } finally { setAiAction('') } }, [artwork?.id, loadAiData, syncCurrentPayload]) const copyText = useCallback(async (value) => { if (!value) return try { await window.navigator?.clipboard?.writeText(value) trackAiEvent('suggestion_copied', { length: value.length }) } catch (err) { console.error('Clipboard write failed:', err) } }, [trackAiEvent]) const applyTitleSuggestion = useCallback((value, mode = 'replace') => { persistAiAction({ title: value, title_mode: mode }) }, [persistAiAction]) const applyDescriptionSuggestion = useCallback((value, mode = 'replace') => { persistAiAction({ description: value, description_mode: mode }) }, [persistAiAction]) const applyTagSuggestions = useCallback((values, mode = 'add') => { const normalized = Array.isArray(values) ? values.filter(Boolean) : [] if (normalized.length === 0) return persistAiAction({ tags: normalized, tag_mode: mode }) }, [persistAiAction]) const applyCategorySuggestion = useCallback((suggestion, mode = 'both') => { if (!suggestion) return const payload = {} if (mode === 'content_type' || mode === 'both') { payload.content_type_id = suggestion.content_type_id || suggestion.id || null } if (mode === 'category' || mode === 'both') { payload.category_id = suggestion.root_category_id || suggestion.id || null } persistAiAction(payload) }, [persistAiAction]) const toggleSuggestedTag = useCallback((tag) => { if (!tag) return setSelectedAiTags((current) => current.includes(tag) ? current.filter((item) => item !== tag) : [...current, tag]) }, []) const handleImproveAll = useCallback(() => { if (aiStatus !== 'ready') { triggerAi('analyze', { intent: 'analyze' }) return } const bestTitle = aiData?.title_suggestions?.[0]?.text const bestDescription = aiData?.description_suggestions?.find((item) => item.variant === 'normal')?.text || aiData?.description_suggestions?.[0]?.text const bestCategory = aiData?.category const payload = {} if (bestTitle) { payload.title = bestTitle payload.title_mode = 'replace' } if (bestDescription) { payload.description = bestDescription payload.description_mode = 'replace' } if (aiSuggestedTags.length > 0) { payload.tags = aiSuggestedTags payload.tag_mode = 'add' } if (bestCategory?.content_type_id) { payload.content_type_id = bestCategory.content_type_id } if (bestCategory?.root_category_id || bestCategory?.id) { payload.category_id = bestCategory.root_category_id || bestCategory.id } if (Object.keys(payload).length > 0) { persistAiAction(payload) } trackAiEvent('improve_all_applied', { applied_title: Boolean(bestTitle), applied_description: Boolean(bestDescription), applied_tags: aiSuggestedTags.length > 0, applied_category: Boolean(bestCategory), }) }, [aiData, aiStatus, aiSuggestedTags, persistAiAction, trackAiEvent, triggerAi]) const aiDebugPayload = useMemo(() => ({ last_editor_request: lastAiRequest, stored_debug: aiData?.debug || null, }), [aiData?.debug, lastAiRequest]) const requestAiIntent = useCallback((intent, action = null) => { const nextAction = action || (aiStatus === 'ready' ? 'regenerate' : 'analyze') trackAiEvent('intent_requested', { intent, action: nextAction }) triggerAi(nextAction, { intent }) }, [aiStatus, trackAiEvent, triggerAi]) const toggleAiPanel = useCallback(() => { setIsAiPanelOpen((current) => { const next = !current trackAiEvent('panel_toggled', { open: next }) return next }) }, [trackAiEvent]) useEffect(() => { loadAiData() }, [loadAiData]) useEffect(() => { if (aiStatus !== 'queued' && aiStatus !== 'processing') return undefined const timer = window.setInterval(() => loadAiData(true), 4000) return () => window.clearInterval(timer) }, [aiStatus, loadAiData]) const handleSave = useCallback(async () => { setSaving(true) setSaved(false) setErrors({}) try { const payload = { title, description, visibility, mode: publishMode, publish_at: publishMode === 'schedule' ? scheduledAt : null, timezone: userTimezone, content_type_id: contentTypeId, category_id: selectedLeafCategoryId, tags: tagSlugs, title_source: titleSource, description_source: descriptionSource, tags_source: tagsSource, category_source: categorySource, } const res = await fetch(`/api/studio/artworks/${artwork.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', body: JSON.stringify(payload), }) if (res.ok) { const data = await res.json() const updatedArtwork = data?.artwork || null if (updatedArtwork) { setVisibility(updatedArtwork.visibility || visibility) setPublishMode(updatedArtwork.publish_mode || 'now') setScheduledAt(updatedArtwork.publish_at || null) } setSaved(true) setTimeout(() => setSaved(false), 3000) } else { const data = await res.json() if (data.errors) setErrors(data.errors) } } catch (err) { console.error('Save failed:', err) } finally { setSaving(false) } }, [title, description, visibility, publishMode, scheduledAt, userTimezone, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id]) const handleFileReplace = async (e) => { const file = e.target.files?.[0] if (!file) return setReplacing(true) try { const fd = new FormData() fd.append('file', file) if (changeNote.trim()) fd.append('change_note', changeNote.trim()) const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, { method: 'POST', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', body: fd, }) const data = await res.json() if (res.ok && data.thumb_url) { setThumbUrl(data.thumb_url) setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 }) if (data.version_number) setVersionCount(data.version_number) if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval) setChangeNote('') setShowChangeNote(false) } else { alert(data.error || 'File replacement failed.') } } catch (err) { console.error('File replace failed:', err) } finally { setReplacing(false) if (fileInputRef.current) fileInputRef.current.value = '' } } const loadVersionHistory = async () => { setHistoryLoading(true) setShowHistory(true) try { const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, { headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', }) setHistoryData(await res.json()) } catch (err) { console.error('Failed to load version history:', err) } finally { setHistoryLoading(false) } } const handleRestoreVersion = async (versionId) => { if (!window.confirm('Restore this version? A copy will become the new current version.')) return setRestoring(versionId) try { const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, { method: 'POST', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, credentials: 'same-origin', }) const data = await res.json() if (res.ok && data.success) { setVersionCount((n) => n + 1) setShowHistory(false) } else { alert(data.error || 'Restore failed.') } } catch (err) { console.error('Restore failed:', err) } finally { setRestoring(null) } } // ── Render ───────────────────────────────────────────────────────────────── return ( {/* ── Page Header ── */}

{title || 'Untitled artwork'}

Editing {visibilitySummary} {heroMeta.map((item) => ( {item} ))}
{/* ── Two-column Layout ── */}
{/* ─────────── LEFT SIDEBAR ─────────── */}
{/* Preview Card */}
Preview {/* Thumbnail */}
{thumbUrl ? ( {title ) : (
)} {replacing && (
)}
{/* File Metadata */}

{fileMeta.name}

{fileMeta.width > 0 && ( {fileMeta.width} × {fileMeta.height} )} {formatBytes(fileMeta.size)}
{/* Version + History */}
v{versionCount}
{requiresReapproval && (

Requires re-approval after replace

)}
{/* Replace File */}
{showChangeNote && ( setChangeNote(e.target.value)} placeholder="Change note (optional)…" size="sm" /> )}
{!showChangeNote && ( )}
{/* Quick Links */}
View Analytics
{/* ─────────── RIGHT MAIN FORM ─────────── */}
{/* ── Tab Nav ── */}
{TABS.map((tab) => (