feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)"

This commit is contained in:
2026-03-03 20:57:43 +01:00
parent dc51d65440
commit b9c2d8597d
114 changed files with 8760 additions and 693 deletions

View File

@@ -1,6 +1,15 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import React, { useState, useMemo, useRef, useCallback } from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import MarkdownEditor from '../../components/ui/MarkdownEditor'
import TextInput from '../../components/ui/TextInput'
import Button from '../../components/ui/Button'
import Toggle from '../../components/ui/Toggle'
import Modal from '../../components/ui/Modal'
import FormField from '../../components/ui/FormField'
import TagPicker from '../../components/tags/TagPicker'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
@@ -21,39 +30,55 @@ function getContentTypeVisualKey(slug) {
function buildCategoryTree(contentTypes) {
return (contentTypes || []).map((ct) => ({
...ct,
rootCategories: (ct.root_categories || []).map((rc) => ({
rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({
...rc,
children: rc.children || [],
})),
}))
}
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Glass-morphism section card (Nova theme) */
function Section({ children, className = '' }) {
return (
<section className={`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>
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function StudioArtworkEdit() {
const { props } = usePage()
const { artwork, contentTypes: rawContentTypes } = props
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
// --- State ---
// ── 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 [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => 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
// File replace
const fileInputRef = useRef(null)
const [replacing, setReplacing] = useState(false)
const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null)
@@ -63,60 +88,24 @@ export default function StudioArtworkEdit() {
width: artwork?.width || 0,
height: artwork?.height || 0,
})
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
const [changeNote, setChangeNote] = useState('')
const [changeNote, setChangeNote] = useState('')
const [showChangeNote, setShowChangeNote] = useState(false)
// Version history modal state
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
// Version history
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
const [historyLoading, setHistoryLoading] = useState(false)
const [restoring, setRestoring] = useState(null) // version id being restored
const [restoring, setRestoring] = useState(null)
// --- 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 ---
// ── 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 || []
// --- Handlers ---
// ── Handlers ───────────────────────────────────────────────────────────────
const handleContentTypeChange = (id) => {
setContentTypeId(id)
setCategoryId(null)
@@ -128,7 +117,7 @@ export default function StudioArtworkEdit() {
setSubCategoryId(null)
}
const handleSave = async () => {
const handleSave = useCallback(async () => {
setSaving(true)
setSaved(false)
setErrors({})
@@ -138,7 +127,7 @@ export default function StudioArtworkEdit() {
description,
is_public: isPublic,
category_id: subCategoryId || categoryId || null,
tags: tags.map((t) => t.slug || t.name),
tags: tagSlugs,
}
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
method: 'PUT',
@@ -152,14 +141,13 @@ export default function StudioArtworkEdit() {
} 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)
}
}
}, [title, description, isPublic, subCategoryId, categoryId, tagSlugs, artwork?.id])
const handleFileReplace = async (e) => {
const file = e.target.files?.[0]
@@ -202,8 +190,7 @@ export default function StudioArtworkEdit() {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setHistoryData(data)
setHistoryData(await res.json())
} catch (err) {
console.error('Failed to load version history:', err)
} finally {
@@ -212,7 +199,7 @@ export default function StudioArtworkEdit() {
}
const handleRestoreVersion = async (versionId) => {
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
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}`, {
@@ -222,7 +209,6 @@ export default function StudioArtworkEdit() {
})
const data = await res.json()
if (res.ok && data.success) {
alert(data.message)
setVersionCount((n) => n + 1)
setShowHistory(false)
} else {
@@ -235,411 +221,422 @@ export default function StudioArtworkEdit() {
}
}
// --- Render ---
// ── 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}
{/* ── Page Header ── */}
<div className="flex items-center justify-between gap-4 mb-8">
<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>
<p className="text-xs text-slate-500 mt-0.5">
Editing &middot;{' '}
<span className={isPublic ? 'text-emerald-400' : 'text-amber-400'}>
{isPublic ? 'Published' : 'Draft'}
</span>
{versionCount > 1 && (
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{saved && (
<span className="text-xs text-emerald-400 flex items-center gap-1 animate-pulse">
<svg width="14" height="14" 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="sm" loading={saving} onClick={handleSave}>
Save changes
</Button>
</div>
</div>
{/* ── Two-column Layout ── */}
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
{/* ─────────── LEFT SIDEBAR ─────────── */}
<div className="space-y-6 lg:sticky lg:top-6">
{/* 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} &times; {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="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-accent transition-colors"
>
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
<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>
</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>
)}
{/* Replace File */}
<div className="mt-4 pt-4 border-t border-white/8 space-y-2.5">
{showChangeNote && (
<textarea
<TextInput
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"
placeholder="Change note (optional)…"
size="sm"
/>
)}
<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"
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="xs"
loading={replacing}
onClick={() => fileInputRef.current?.click()}
>
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'}
</button>
{showChangeNote && (
</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"
onClick={() => setShowChangeNote(true)}
className="text-[11px] text-slate-500 hover:text-white transition-colors"
>
<i className="fa-solid fa-upload" /> Choose file
+ note
</button>
)}
</div>
<input ref={fileInputRef} type="file" className="hidden" accept="image/*" onChange={handleFileReplace} />
</div>
</div>
</section>
</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" />
{/* 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="space-y-6">
{/* ── Content Type ── */}
<Section>
<SectionTitle icon="fa-solid fa-palette">Content Type</SectionTitle>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{contentTypes.map((ct) => {
const isActive = contentTypeId === ct.id
const visualKey = getContentTypeVisualKey(ct.slug)
return (
<button
key={ct.id}
type="button"
onClick={() => handleContentTypeChange(ct.id)}
className={[
'group flex flex-col items-center gap-2 rounded-xl border p-3 text-center transition-all',
isActive
? 'border-emerald-400/50 bg-emerald-400/10 ring-1 ring-emerald-400/30'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<img
src={`/gfx/mascot_${visualKey}.webp`}
alt=""
className="h-10 w-10 object-contain"
onError={(e) => { e.target.style.display = 'none' }}
/>
<span className={`text-xs font-medium ${isActive ? 'text-emerald-300' : 'text-slate-400 group-hover:text-white'}`}>
{ct.name}
</span>
)}
</button>
)
})}
</div>
</section>
</button>
)
})}
</div>
</Section>
{/* ── Category ── */}
{rootCategories.length > 0 && (
<Section className="space-y-4">
<SectionTitle icon="fa-solid fa-layer-group">Category</SectionTitle>
{/* ── 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
const isActive = categoryId === cat.id
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'}`}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
isActive
? 'border-purple-400/50 bg-purple-400/15 text-purple-300'
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.06]',
].join(' ')}
>
{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>
)
})}
{subCategories.length > 0 && (
<div className="space-y-2 pl-1 border-l-2 border-white/5 ml-2">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 pl-3">Subcategory</h4>
<div className="flex flex-wrap gap-2 pl-3">
{subCategories.map((sub) => {
const isActive = subCategoryId === sub.id
return (
<button
key={sub.id}
type="button"
onClick={() => setSubCategoryId(sub.id)}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
isActive
? 'border-cyan-400/50 bg-cyan-400/15 text-cyan-300'
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.06]',
].join(' ')}
>
{sub.name}
</button>
)
})}
</div>
</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>
{errors.category_id && <p className="text-xs text-red-400">{errors.category_id[0]}</p>}
</Section>
)}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
<input
type="text"
{/* ── Details (Title + Description) ── */}
<Section className="space-y-5">
<SectionTitle icon="fa-solid fa-pen-fancy">Details</SectionTitle>
<TextInput
label="Title"
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"
placeholder="Give your artwork a title"
error={errors.title?.[0]}
required
/>
{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>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
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 resize-y"
<FormField label="Description" htmlFor="artwork-description">
<MarkdownEditor
id="artwork-description"
value={description}
onChange={setDescription}
placeholder="Describe your artwork, tools, inspiration…"
rows={6}
error={errors.description?.[0]}
/>
</FormField>
</Section>
{/* ── Tags ── */}
<Section className="space-y-4">
<SectionTitle icon="fa-solid fa-tags">Tags</SectionTitle>
<TagPicker
value={tagSlugs}
onChange={setTagSlugs}
searchEndpoint="/api/studio/tags/search"
popularEndpoint="/api/studio/tags/search"
error={errors.tags?.[0]}
/>
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
</div>
</section>
</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" />
{/* ── Visibility ── */}
<Section>
<div className="flex items-center justify-between">
<div className="space-y-1">
<SectionTitle icon="fa-solid fa-eye">Visibility</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
{isPublic
? 'Your artwork is visible to everyone'
: 'Your artwork is only visible to you'}
</p>
</div>
<Toggle
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
label={isPublic ? 'Published' : 'Draft'}
variant={isPublic ? 'emerald' : 'accent'}
size="md"
/>
</div>
</Section>
{/* ── Bottom Save Bar (mobile) ── */}
<div className="flex items-center justify-between gap-3 py-2 lg:hidden">
<Button variant="accent" loading={saving} onClick={handleSave}>
Save changes
</Button>
{saved && (
<span className="text-xs text-emerald-400 flex items-center gap-1">
<svg width="14" height="14" 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>
)}
{!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"
<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(' ')}
>
<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 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>
{!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>
<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} &times; {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
variant="ghost"
size="xs"
loading={restoring === v.id}
onClick={() => handleRestoreVersion(v.id)}
>
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>
{historyData.versions.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
)}
</div>
</div>
)}
)}
</Modal>
</StudioLayout>
)
}