import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { usePage, Link } from '@inertiajs/react' import StudioLayout from '../../Layouts/StudioLayout' import RichTextEditor from '../../components/forum/RichTextEditor' import TextInput from '../../components/ui/TextInput' import Button from '../../components/ui/Button' import Modal from '../../components/ui/Modal' import FormField from '../../components/ui/FormField' import Toggle from '../../components/ui/Toggle' import NovaSelect from '../../components/ui/NovaSelect' import TagPicker from '../../components/tags/TagPicker' import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker' import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker' const EDIT_SECTIONS = [ { id: 'taxonomy', label: 'Category', hint: 'Content type and category path' }, { id: 'details', label: 'Details', hint: 'Title and description' }, { id: 'evolution', label: 'Evolution', hint: 'Link an older original artwork' }, { id: 'ai-assist', label: 'AI Assist', hint: 'Suggestions and similar matches' }, { id: 'tags', label: 'Tags', hint: 'Search, add, and refine keywords' }, { id: 'visibility', label: 'Visibility', hint: 'Publishing state' }, ] const TABS = [ { id: 'details', label: 'Details', icon: 'fa-solid fa-pen-fancy' }, { id: 'media', label: 'Media', icon: 'fa-solid fa-photo-film' }, { id: 'evolution', label: 'Evolution', icon: 'fa-solid fa-code-branch' }, { id: 'tags', label: 'Tags', icon: 'fa-solid fa-tags' }, { id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' }, { id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' }, { id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' }, ] // ─── Helpers ───────────────────────────────────────────────────────────────── function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } function formatBytes(bytes) { if (!bytes) return '—' if (bytes < 1024) return bytes + ' B' if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB' return (bytes / 1048576).toFixed(1) + ' MB' } function resolveFileExtension(fileName, fallbackExt = '') { const normalizedFallback = String(fallbackExt || '').trim().replace(/^\./, '').toLowerCase() const normalizedName = String(fileName || '').trim() const fromName = normalizedName.includes('.') ? normalizedName.split('.').pop()?.trim().toLowerCase() : '' return fromName || normalizedFallback } function isArchiveArtwork(fileName, mimeType, fileExt) { const extension = resolveFileExtension(fileName, fileExt) if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return true const normalizedMime = String(mimeType || '').toLowerCase() return normalizedMime.includes('zip') || normalizedMime.includes('rar') || normalizedMime.includes('7z') || normalizedMime.includes('tar') || normalizedMime.includes('gzip') } function formatSchedulePreview(value, timezone) { if (!value) return 'Pick a date and time' const date = new Date(value) if (Number.isNaN(date.getTime())) return 'Pick a date and time' try { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: timezone || undefined, }).format(date) } catch { return date.toLocaleString() } } function formatReleaseCountdown(value, nowMs = Date.now()) { if (!value) return '' const releaseDate = new Date(value) if (Number.isNaN(releaseDate.getTime())) return '' const remainingMs = releaseDate.getTime() - nowMs if (remainingMs <= 0) { return 'Releasing now' } const totalSeconds = Math.floor(remainingMs / 1000) const days = Math.floor(totalSeconds / 86400) const hours = Math.floor((totalSeconds % 86400) / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 const parts = [] if (days > 0) parts.push(`${days}d`) if (days > 0 || hours > 0) parts.push(`${hours}h`) if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`) if (days === 0) parts.push(`${seconds}s`) return `In ${parts.join(' ')}` } function getContentTypeVisualKey(slug) { const map = { skins: 'skins', wallpapers: 'wallpapers', photography: 'photography', other: 'other', members: 'members' } return map[slug] || 'other' } function buildCategoryTree(contentTypes) { return (contentTypes || []).map((ct) => ({ ...ct, rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({ ...rc, children: rc.children || [], })), })) } function nextSourceForManualEdit(currentSource) { if (currentSource === 'ai_applied' || currentSource === 'ai_generated') return 'mixed' if (currentSource === 'mixed') return 'mixed' return 'manual' } function statusTone(status) { switch (status) { case 'ready': return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200' case 'queued': case 'processing': return 'border-sky-400/30 bg-sky-400/10 text-sky-200' case 'failed': return 'border-red-400/30 bg-red-400/10 text-red-200' default: return 'border-white/10 bg-white/[0.04] text-slate-300' } } function statusLabel(status) { switch (status) { case 'queued': return 'Queued' case 'processing': return 'Processing' case 'ready': return 'Ready' case 'failed': return 'Failed' case 'pending': return 'Pending' default: return 'Not analyzed' } } function visibilityLabel(value) { switch (value) { case 'unlisted': return 'Unlisted' case 'private': return 'Private' default: return 'Public' } } function normalizeContributorCredits(contributorIds = [], contributorCredits = {}) { const normalized = {} const ids = Array.isArray(contributorIds) ? contributorIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [] ids.forEach((id) => { const current = contributorCredits?.[id] || contributorCredits?.[String(id)] || {} normalized[id] = { creditRole: typeof current.creditRole === 'string' ? current.creditRole : '', isPrimary: Boolean(current.isPrimary), } }) const leadIds = Object.entries(normalized) .filter(([, value]) => value.isPrimary) .map(([id]) => Number(id)) if (leadIds.length > 1) { leadIds.slice(1).forEach((id) => { normalized[id] = { ...normalized[id], isPrimary: false, } }) } return normalized } function mapContributorCredits(contributorCredits = []) { return (Array.isArray(contributorCredits) ? contributorCredits : []).reduce((accumulator, contributor) => { const userId = Number(contributor?.user_id) if (!Number.isFinite(userId) || userId <= 0) return accumulator accumulator[userId] = { creditRole: typeof contributor?.credit_role === 'string' ? contributor.credit_role : '', isPrimary: Boolean(contributor?.is_primary), } return accumulator }, {}) } // ─── Sub-components ────────────────────────────────────────────────────────── /** Glass-morphism section card (Nova theme) */ function Section({ children, className = '', id = undefined }) { return (
{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 groupOptions = Array.isArray(props.groupOptions) ? props.groupOptions : [] const evolutionRelationTypes = Array.isArray(props.evolutionRelationTypes) ? props.evolutionRelationTypes : [] const initialEvolutionRelation = artwork?.evolution_relation || null const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object' ? props.contributorOptionsByGroup : {} const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes]) // ── State ────────────────────────────────────────────────────────────────── const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null) const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null) const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null) const [title, setTitle] = useState(artwork?.title || '') const [description, setDescription] = useState(artwork?.description || '') const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name)) const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private')) const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now')) const [scheduledAt, setScheduledAt] = useState(artwork?.publish_at || null) const [groupSlug, setGroupSlug] = useState(artwork?.group_slug || '') const [primaryAuthorUserId, setPrimaryAuthorUserId] = useState(artwork?.primary_author_user_id || null) const [contributorUserIds, setContributorUserIds] = useState(() => (Array.isArray(artwork?.contributor_user_ids) ? artwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])) const [contributorCredits, setContributorCredits] = useState(() => normalizeContributorCredits(artwork?.contributor_user_ids || [], mapContributorCredits(artwork?.contributor_credits || []))) const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual') const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual') const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual') const [categorySource, setCategorySource] = useState(artwork?.category_source || 'manual') const [evolutionTarget, setEvolutionTarget] = useState(initialEvolutionRelation?.target_artwork || null) const [evolutionRelationType, setEvolutionRelationType] = useState(initialEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of') const [evolutionNote, setEvolutionNote] = useState(initialEvolutionRelation?.note || '') const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [errors, setErrors] = useState({}) const [aiData, setAiData] = useState(null) const [aiLoading, setAiLoading] = useState(false) const [aiAction, setAiAction] = useState('') const [aiDirect, setAiDirect] = useState(false) const [isAiPanelOpen, setIsAiPanelOpen] = useState(true) const [isAiDebugOpen, setIsAiDebugOpen] = useState(false) const [lastAiRequest, setLastAiRequest] = useState(null) const [selectedAiTags, setSelectedAiTags] = useState([]) const [activeTab, setActiveTab] = useState('details') const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !artwork?.parent_category_id) const [nowMs, setNowMs] = useState(() => Date.now()) const userTimezone = useMemo(() => artwork?.artwork_timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [artwork?.artwork_timezone]) // File replace const fileInputRef = useRef(null) const [replacing, setReplacing] = useState(false) const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null) const downloadUrl = artwork?.download_url || (artwork?.id ? `/download/artwork/${artwork.id}` : null) const [selectedMediaId, setSelectedMediaId] = useState('cover') const [fileExt, setFileExt] = useState(artwork?.file_ext || '') const [mimeType, setMimeType] = useState(artwork?.mime_type || '') const [hasArchiveFile, setHasArchiveFile] = useState(Boolean(artwork?.has_archive_file)) const [artworkScreenshots, setArtworkScreenshots] = useState(() => (Array.isArray(artwork?.screenshots) ? artwork.screenshots : [])) const [fileMeta, setFileMeta] = useState({ name: artwork?.file_name || '—', size: artwork?.file_size || 0, width: artwork?.width || 0, height: artwork?.height || 0, }) const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1) const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false) const [changeNote, setChangeNote] = useState('') const [showChangeNote, setShowChangeNote] = useState(false) // Version history const [showHistory, setShowHistory] = useState(false) const [historyData, setHistoryData] = useState(null) const [historyLoading, setHistoryLoading] = useState(false) const [restoring, setRestoring] = useState(null) const [archiveRevisionSaving, setArchiveRevisionSaving] = useState(false) const [archiveRevisionError, setArchiveRevisionError] = useState('') const [archiveCoverFile, setArchiveCoverFile] = useState(null) const [archiveCoverPreview, setArchiveCoverPreview] = useState(null) const [archivePackageFile, setArchivePackageFile] = useState(null) const [archiveExtraScreenshots, setArchiveExtraScreenshots] = useState([]) const [archiveExtraPreviews, setArchiveExtraPreviews] = useState([]) // Per-slot screenshot replacement: { slotIndex: File } const [replaceShots, setReplaceShots] = useState({}) const [replaceShotPreviews, setReplaceShotPreviews] = useState({}) const [removedShots, setRemovedShots] = useState({}) // Staged single-image replace (no auto-upload) const [pendingReplaceFile, setPendingReplaceFile] = useState(null) const [pendingReplacePreview, setPendingReplacePreview] = useState(null) // Drag-over tracking for drop zones const [dragOverZone, setDragOverZone] = useState(null) const screenshotItems = artworkScreenshots const activeScreenshotCount = screenshotItems.filter((_, index) => !removedShots[index]).length const currentFileExt = resolveFileExtension(fileMeta.name, fileExt) const archiveArtwork = hasArchiveFile || isArchiveArtwork(fileMeta.name, mimeType, fileExt) const quickReplaceSupported = !archiveArtwork const mediaItems = useMemo(() => { const coverItem = { id: 'cover', label: archiveArtwork ? 'Cover preview' : 'Main artwork', url: thumbUrl, width: fileMeta.width || 0, height: fileMeta.height || 0, } const screenshotMedia = screenshotItems.map((item, index) => ({ id: item.id || `shot-${index + 1}`, label: item.label || `Screenshot ${index + 1}`, url: item.thumb_url || item.url || null, width: 0, height: 0, })) return [coverItem, ...screenshotMedia].filter((item) => Boolean(item.url)) }, [archiveArtwork, fileMeta.height, fileMeta.width, screenshotItems, thumbUrl]) const activeMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null const activeMediaLabel = activeMedia?.label || (archiveArtwork ? 'Cover preview' : 'Main artwork') // ── Derived ──────────────────────────────────────────────────────────────── const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null const rootCategories = selectedCT?.rootCategories || [] const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null const subCategories = selectedRoot?.children || [] const selectedGroupOption = useMemo(() => groupOptions.find((group) => String(group.slug || '') === String(groupSlug || '')) || null, [groupOptions, groupSlug]) const currentContributorOptions = useMemo(() => { const selectedSlug = String(groupSlug || '') return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : [] }, [contributorOptionsByGroup, groupSlug]) const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed' const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData]) const selectedLeafCategoryId = subCategoryId || categoryId || null const visibilitySummary = publishMode === 'schedule' ? `Scheduled as ${visibilityLabel(visibility)}` : visibilityLabel(visibility) const selectedSubCategory = subCategoryId ? subCategories.find((item) => item.id === subCategoryId) || null : null const heroMeta = [ selectedCT?.name || 'No content type', selectedRoot?.name || 'No root category', selectedSubCategory?.name || null, ].filter(Boolean) const categoryPreviewSummary = [selectedCT?.name, selectedRoot?.name, selectedSubCategory?.name].filter(Boolean).join(' / ') || 'Choose a category path' const visibilityPreviewHint = publishMode === 'schedule' ? 'Hidden until the scheduled publish time.' : visibility === 'private' ? 'Draft-only visibility.' : visibility === 'unlisted' ? 'Accessible by direct link.' : 'Visible to everyone immediately.' const hasScheduledRelease = publishMode === 'schedule' && Boolean(scheduledAt) const schedulePreviewSummary = hasScheduledRelease ? formatReleaseCountdown(scheduledAt, nowMs) : '' const schedulePreviewHint = hasScheduledRelease ? formatSchedulePreview(scheduledAt, userTimezone) : '' const publishingIdentityOptions = useMemo(() => { const personalOption = { value: '', label: 'Personal profile', icon: