Files
SkinbaseNova/resources/js/Pages/Studio/StudioArtworkEdit.jsx.bak
2026-03-03 21:00:18 +01:00

649 lines
27 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &middot; {formatBytes(v.file_size)}</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</p>
)}
</div>
{!v.is_current && (
<button
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 versionnothing is deleted.
</p>
</div>
</div>
</div>
)}
</StudioLayout>
)
}