fixes
This commit is contained in:
648
resources/js/Pages/Studio/StudioArtworkEdit.jsx.bak
Normal file
648
resources/js/Pages/Studio/StudioArtworkEdit.jsx.bak
Normal file
@@ -0,0 +1,648 @@
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import MarkdownEditor from '../../components/ui/MarkdownEditor'
|
||||
|
||||
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 || [],
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
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 [tags, setTags] = useState(() => (artwork?.tags || []).map((t) => ({ id: t.id, name: t.name, slug: t.slug || t.name })))
|
||||
const [isPublic, setIsPublic] = useState(artwork?.is_public ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
// Tag picker state
|
||||
const [tagQuery, setTagQuery] = useState('')
|
||||
const [tagResults, setTagResults] = useState([])
|
||||
const [tagLoading, setTagLoading] = useState(false)
|
||||
const tagInputRef = useRef(null)
|
||||
const tagSearchTimer = useRef(null)
|
||||
|
||||
// File replace state
|
||||
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 modal state
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [historyData, setHistoryData] = useState(null)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [restoring, setRestoring] = useState(null) // version id being restored
|
||||
|
||||
// --- Tag search ---
|
||||
const searchTags = useCallback(async (q) => {
|
||||
setTagLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setTagResults(data || [])
|
||||
} catch {
|
||||
setTagResults([])
|
||||
} finally {
|
||||
setTagLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(tagSearchTimer.current)
|
||||
tagSearchTimer.current = setTimeout(() => searchTags(tagQuery), 250)
|
||||
return () => clearTimeout(tagSearchTimer.current)
|
||||
}, [tagQuery, searchTags])
|
||||
|
||||
const toggleTag = (tag) => {
|
||||
setTags((prev) => {
|
||||
const exists = prev.find((t) => t.id === tag.id)
|
||||
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name, slug: tag.slug }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTag = (id) => {
|
||||
setTags((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
// --- Derived data ---
|
||||
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 || []
|
||||
|
||||
// --- Handlers ---
|
||||
const handleContentTypeChange = (id) => {
|
||||
setContentTypeId(id)
|
||||
setCategoryId(null)
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
|
||||
const handleCategoryChange = (id) => {
|
||||
setCategoryId(id)
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
setErrors({})
|
||||
try {
|
||||
const payload = {
|
||||
title,
|
||||
description,
|
||||
is_public: isPublic,
|
||||
category_id: subCategoryId || categoryId || null,
|
||||
tags: tags.map((t) => t.slug || t.name),
|
||||
}
|
||||
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) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
if (data.errors) setErrors(data.errors)
|
||||
console.error('Save failed:', data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
const data = await res.json()
|
||||
setHistoryData(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreVersion = async (versionId) => {
|
||||
if (!window.confirm('Restore this version? It will be cloned as 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) {
|
||||
alert(data.message)
|
||||
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">
|
||||
<Link
|
||||
href="/studio/artworks"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Back to Artworks
|
||||
</Link>
|
||||
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{/* ── Uploaded Asset ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{requiresReapproval && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
||||
<i className="fa-solid fa-triangle-exclamation" /> Under Review
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
|
||||
v{versionCount}
|
||||
</span>
|
||||
{versionCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadVersionHistory}
|
||||
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
|
||||
>
|
||||
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-5">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-xl bg-nova-800 flex items-center justify-center text-slate-600 flex-shrink-0">
|
||||
<i className="fa-solid fa-image text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm text-white font-medium truncate">{fileMeta.name}</p>
|
||||
<p className="text-xs text-slate-400">{formatBytes(fileMeta.size)}</p>
|
||||
{fileMeta.width > 0 && (
|
||||
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
|
||||
)}
|
||||
{showChangeNote && (
|
||||
<textarea
|
||||
value={changeNote}
|
||||
onChange={(e) => setChangeNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="What changed? (optional)"
|
||||
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
|
||||
/>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowChangeNote((s) => !s)
|
||||
if (!showChangeNote) fileInputRef.current?.click()
|
||||
}}
|
||||
disabled={replacing}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
|
||||
{replacing ? 'Replacing…' : 'Replace file'}
|
||||
</button>
|
||||
{showChangeNote && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={replacing}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-upload" /> Choose file
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Content Type ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Content Type</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{contentTypes.map((ct) => {
|
||||
const active = ct.id === contentTypeId
|
||||
const vk = getContentTypeVisualKey(ct.slug)
|
||||
return (
|
||||
<button
|
||||
key={ct.id}
|
||||
type="button"
|
||||
onClick={() => handleContentTypeChange(ct.id)}
|
||||
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all cursor-pointer
|
||||
${active ? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-400/10' : 'border-white/10 bg-white/5 hover:border-white/20'}`}
|
||||
>
|
||||
<img src={`/gfx/mascot_${vk}.webp`} alt={ct.name} className="w-14 h-14 object-contain" />
|
||||
<span className={`text-xs font-semibold ${active ? 'text-emerald-300' : 'text-slate-300'}`}>{ct.name}</span>
|
||||
{active && (
|
||||
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center">
|
||||
<i className="fa-solid fa-check text-[10px] text-white" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Category ── */}
|
||||
{rootCategories.length > 0 && (
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Category</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rootCategories.map((cat) => {
|
||||
const active = cat.id === categoryId
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
|
||||
${active ? 'border-purple-600/90 bg-purple-700/35 text-purple-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subcategory */}
|
||||
{subCategories.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Subcategory</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subCategories.map((sub) => {
|
||||
const active = sub.id === subCategoryId
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => setSubCategoryId(active ? null : sub.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
|
||||
${active ? 'border-cyan-600/90 bg-cyan-700/35 text-cyan-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
|
||||
>
|
||||
{sub.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Basics ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">Basics</h3>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-400 mt-1">{errors.title[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Description</label>
|
||||
<MarkdownEditor
|
||||
id="studio-edit-description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Describe your artwork…"
|
||||
rows={5}
|
||||
error={errors.description}
|
||||
/>
|
||||
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Tags ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Tags</h3>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
value={tagQuery}
|
||||
onChange={(e) => setTagQuery(e.target.value)}
|
||||
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
placeholder="Search tags…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected tag chips */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-accent/20 text-accent"
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => removeTag(tag.id)}
|
||||
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-[10px]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
<div className="max-h-48 overflow-y-auto sb-scrollbar space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
|
||||
{tagLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tagLoading && tagResults.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-500 py-4">
|
||||
{tagQuery ? 'No tags found' : 'Type to search tags'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!tagLoading &&
|
||||
tagResults.map((tag) => {
|
||||
const isSelected = tags.some((t) => t.id === tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
||||
isSelected
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<i
|
||||
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
|
||||
isSelected ? 'text-accent' : 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">{tags.length}/15 tags selected</p>
|
||||
{errors.tags && <p className="text-xs text-red-400">{errors.tags[0]}</p>}
|
||||
</section>
|
||||
|
||||
{/* ── Visibility ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Visibility</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={isPublic} onChange={() => setIsPublic(true)} className="text-accent focus:ring-accent/50" />
|
||||
<span className="text-sm text-white">Published</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={!isPublic} onChange={() => setIsPublic(false)} className="text-accent focus:ring-accent/50" />
|
||||
<span className="text-sm text-white">Draft</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
|
||||
{saved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1">
|
||||
<i className="fa-solid fa-check" /> Saved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/studio/artworks/${artwork?.id}/analytics`}
|
||||
className="ml-auto px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line mr-2" />
|
||||
Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Version History Modal ── */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
|
||||
>
|
||||
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<i className="fa-solid fa-clock-rotate-left text-accent" />
|
||||
Version History
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowHistory(false)}
|
||||
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
|
||||
{historyLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!historyLoading && historyData && 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]'
|
||||
}`}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
disabled={restoring === v.id}
|
||||
onClick={() => handleRestoreVersion(v.id)}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{restoring === v.id
|
||||
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring…</>
|
||||
: <><i className="fa-solid fa-rotate-left" /> Restore</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!historyLoading && historyData && historyData.versions.length === 0 && (
|
||||
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/10">
|
||||
<p className="text-xs text-slate-500">
|
||||
Older versions are preserved. Restoring creates a new version—nothing is deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user