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:
102
resources/js/Layouts/SettingsLayout.jsx
Normal file
102
resources/js/Layouts/SettingsLayout.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' },
|
||||
// Future: { label: 'Notifications', href: '/dashboard/notifications', icon: 'fa-solid fa-bell' },
|
||||
// Future: { label: 'Privacy', href: '/dashboard/privacy', icon: 'fa-solid fa-shield-halved' },
|
||||
]
|
||||
|
||||
function NavLink({ item, active, onClick }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ isActive, onNavigate }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item.href)} onClick={onNavigate} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 space-y-2">
|
||||
<Link
|
||||
href="/studio"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<i className="fa-solid fa-palette w-5 text-center" />
|
||||
Creator Studio
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ children, title }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const isActive = (href) => url.startsWith(href)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nova-900">
|
||||
{/* Mobile top bar */}
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
|
||||
<h1 className="text-lg font-bold text-white">Settings</h1>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="text-slate-400 hover:text-white p-2"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile nav overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
|
||||
<nav
|
||||
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} />
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
|
||||
<SidebarContent isActive={isActive} />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-4xl">
|
||||
{title && (
|
||||
<h1 className="text-2xl font-bold text-white mb-6">{title}</h1>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default function HomeHero({ artwork, isLoggedIn }) {
|
||||
src={src}
|
||||
alt={artwork.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||
fetchpriority="high"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
727
resources/js/Pages/Settings/ProfileEdit.jsx
Normal file
727
resources/js/Pages/Settings/ProfileEdit.jsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SettingsLayout from '../../Layouts/SettingsLayout'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import Textarea from '../../components/ui/Textarea'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Toggle from '../../components/ui/Toggle'
|
||||
import Select from '../../components/ui/Select'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
import { RadioGroup } from '../../components/ui/Radio'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
{ value: '1', label: 'January' }, { value: '2', label: 'February' },
|
||||
{ value: '3', label: 'March' }, { value: '4', label: 'April' },
|
||||
{ value: '5', label: 'May' }, { value: '6', label: 'June' },
|
||||
{ value: '7', label: 'July' }, { value: '8', label: 'August' },
|
||||
{ value: '9', label: 'September' }, { value: '10', label: 'October' },
|
||||
{ value: '11', label: 'November' }, { value: '12', label: 'December' },
|
||||
]
|
||||
|
||||
const GENDER_OPTIONS = [
|
||||
{ value: 'm', label: 'Male' },
|
||||
{ value: 'f', label: 'Female' },
|
||||
{ value: 'x', label: 'Prefer not to say' },
|
||||
]
|
||||
|
||||
function buildDayOptions() {
|
||||
return Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))
|
||||
}
|
||||
|
||||
function buildYearOptions() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = []
|
||||
for (let y = currentYear; y >= currentYear - 100; y--) {
|
||||
years.push({ value: String(y), label: String(y) })
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ children, className = '' }) {
|
||||
return (
|
||||
<section className={`bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, children, description }) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
|
||||
{children}
|
||||
</h3>
|
||||
{description && <p className="text-xs text-slate-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProfileEdit() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
user,
|
||||
avatarUrl: initialAvatarUrl,
|
||||
birthDay: initDay,
|
||||
birthMonth: initMonth,
|
||||
birthYear: initYear,
|
||||
countries = [],
|
||||
flash = {},
|
||||
} = props
|
||||
|
||||
// ── Profile State ──────────────────────────────────────────────────────────
|
||||
const [name, setName] = useState(user?.name || '')
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [homepage, setHomepage] = useState(user?.homepage || user?.website || '')
|
||||
const [about, setAbout] = useState(user?.about_me || user?.about || '')
|
||||
const [signature, setSignature] = useState(user?.signature || '')
|
||||
const [description, setDescription] = useState(user?.description || '')
|
||||
const [day, setDay] = useState(initDay ? String(parseInt(initDay, 10)) : '')
|
||||
const [month, setMonth] = useState(initMonth ? String(parseInt(initMonth, 10)) : '')
|
||||
const [year, setYear] = useState(initYear ? String(initYear) : '')
|
||||
const [gender, setGender] = useState(() => {
|
||||
const g = (user?.gender || '').toLowerCase()
|
||||
if (g === 'm') return 'm'
|
||||
if (g === 'f') return 'f'
|
||||
if (g === 'x' || g === 'n') return 'x'
|
||||
return ''
|
||||
})
|
||||
const [country, setCountry] = useState(user?.country_code || user?.country || '')
|
||||
const [mailing, setMailing] = useState(!!user?.mlist)
|
||||
const [notify, setNotify] = useState(!!user?.friend_upload_notice)
|
||||
const [autoPost, setAutoPost] = useState(!!user?.auto_post_upload)
|
||||
|
||||
// Avatar
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
|
||||
const [avatarFile, setAvatarFile] = useState(null)
|
||||
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||
const avatarInputRef = useRef(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
|
||||
// Save state
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [profileErrors, setProfileErrors] = useState({})
|
||||
const [profileSaved, setProfileSaved] = useState(!!flash?.status)
|
||||
|
||||
// Password state
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [passwordErrors, setPasswordErrors] = useState({})
|
||||
const [passwordSaved, setPasswordSaved] = useState(false)
|
||||
|
||||
// Delete account
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
|
||||
// ── Country Options ─────────────────────────────────────────────────────────
|
||||
const countryOptions = (countries || []).map((c) => ({
|
||||
value: c.country_code || c.code || c.id || '',
|
||||
label: c.country_name || c.name || '',
|
||||
}))
|
||||
|
||||
// ── Avatar Handlers ────────────────────────────────────────────────────────
|
||||
const handleAvatarSelect = (file) => {
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
setAvatarFile(file)
|
||||
setAvatarUrl(URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
const handleAvatarUpload = useCallback(async () => {
|
||||
if (!avatarFile) return
|
||||
setAvatarUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('avatar', avatarFile)
|
||||
const res = await fetch('/avatar/upload', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.url) {
|
||||
setAvatarUrl(data.url)
|
||||
setAvatarFile(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Avatar upload failed:', err)
|
||||
} finally {
|
||||
setAvatarUploading(false)
|
||||
}
|
||||
}, [avatarFile])
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragIn = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
const handleDragOut = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) handleAvatarSelect(file)
|
||||
}
|
||||
|
||||
// ── Profile Save ───────────────────────────────────────────────────────────
|
||||
const handleProfileSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setProfileSaved(false)
|
||||
setProfileErrors({})
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('_method', 'PUT')
|
||||
fd.append('email', email)
|
||||
fd.append('username', username)
|
||||
fd.append('name', name)
|
||||
if (homepage) fd.append('web', homepage)
|
||||
if (about) fd.append('about', about)
|
||||
if (signature) fd.append('signature', signature)
|
||||
if (description) fd.append('description', description)
|
||||
if (day) fd.append('day', day)
|
||||
if (month) fd.append('month', month)
|
||||
if (year) fd.append('year', year)
|
||||
if (gender) fd.append('gender', gender)
|
||||
if (country) fd.append('country', country)
|
||||
fd.append('mailing', mailing ? '1' : '0')
|
||||
fd.append('notify', notify ? '1' : '0')
|
||||
fd.append('auto_post_upload', autoPost ? '1' : '0')
|
||||
if (avatarFile) fd.append('avatar', avatarFile)
|
||||
|
||||
const res = await fetch('/profile', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 302) {
|
||||
setProfileSaved(true)
|
||||
setAvatarFile(null)
|
||||
setTimeout(() => setProfileSaved(false), 4000)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (data.errors) setProfileErrors(data.errors)
|
||||
else if (data.message) setProfileErrors({ _general: [data.message] })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Profile save failed:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [email, username, name, homepage, about, signature, description, day, month, year, gender, country, mailing, notify, autoPost, avatarFile])
|
||||
|
||||
// ── Password Change ────────────────────────────────────────────────────────
|
||||
const handlePasswordChange = useCallback(async () => {
|
||||
setPasswordSaving(true)
|
||||
setPasswordSaved(false)
|
||||
setPasswordErrors({})
|
||||
try {
|
||||
const res = await fetch('/profile/password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: confirmPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 302) {
|
||||
setPasswordSaved(true)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setTimeout(() => setPasswordSaved(false), 4000)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (data.errors) setPasswordErrors(data.errors)
|
||||
else if (data.message) setPasswordErrors({ _general: [data.message] })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Password change failed:', err)
|
||||
} finally {
|
||||
setPasswordSaving(false)
|
||||
}
|
||||
}, [currentPassword, newPassword, confirmPassword])
|
||||
|
||||
// ── Delete Account ─────────────────────────────────────────────────────────
|
||||
const handleDeleteAccount = async () => {
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
try {
|
||||
const res = await fetch('/profile', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ password: deletePassword }),
|
||||
})
|
||||
if (res.ok || res.status === 302) {
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setDeleteError(data.errors?.password?.[0] || data.message || 'Deletion failed.')
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError('Request failed.')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<SettingsLayout title="Edit Profile">
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* ── General Errors ── */}
|
||||
{profileErrors._general && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{profileErrors._general[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
AVATAR SECTION
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-camera" description="JPG, PNG or WebP. Max 2 MB.">
|
||||
Avatar
|
||||
</SectionTitle>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Preview */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={username || 'Avatar'}
|
||||
className="w-24 h-24 rounded-full object-cover ring-2 ring-white/10 shadow-lg"
|
||||
/>
|
||||
{avatarUploading && (
|
||||
<div className="absolute inset-0 rounded-full bg-black/60 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropzone */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
onDragEnter={handleDragIn}
|
||||
onDragLeave={handleDragOut}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={[
|
||||
'w-full rounded-xl border-2 border-dashed px-5 py-5 text-left transition-all',
|
||||
dragActive
|
||||
? 'border-accent/50 bg-accent/10'
|
||||
: 'border-white/15 hover:border-white/25 hover:bg-white/[0.03]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-10 h-10 rounded-lg bg-white/5 text-slate-400">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 0113.5 13H11V9.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 101.414 1.414L9 9.414V13H5.5z" />
|
||||
<path d="M9 13h2v5a1 1 0 11-2 0v-5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white/90 font-medium">
|
||||
{avatarFile ? avatarFile.name : 'Drop an image or click to browse'}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
{avatarFile ? 'Ready to upload with save' : 'Recommended: 256×256 px or larger'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
|
||||
/>
|
||||
{avatarFile && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button variant="accent" size="xs" loading={avatarUploading} onClick={handleAvatarUpload}>
|
||||
Upload now
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAvatarFile(null); setAvatarUrl(initialAvatarUrl || '') }}
|
||||
className="text-[11px] text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ACCOUNT INFO
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-user" description="Your public identity on Skinbase.">
|
||||
Account
|
||||
</SectionTitle>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<TextInput
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
error={profileErrors.username?.[0]}
|
||||
hint={user?.username_changed_at ? `Last changed: ${new Date(user.username_changed_at).toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={profileErrors.email?.[0]}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your real name (optional)"
|
||||
error={profileErrors.name?.[0]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Website"
|
||||
type="url"
|
||||
value={homepage}
|
||||
onChange={(e) => setHomepage(e.target.value)}
|
||||
placeholder="https://"
|
||||
error={profileErrors.web?.[0] || profileErrors.homepage?.[0]}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ABOUT & BIO
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-pen-fancy" description="Tell the community about yourself.">
|
||||
About & Bio
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Textarea
|
||||
label="About Me"
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Share something about yourself…"
|
||||
rows={4}
|
||||
error={profileErrors.about?.[0]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<Textarea
|
||||
label="Signature"
|
||||
value={signature}
|
||||
onChange={(e) => setSignature(e.target.value)}
|
||||
placeholder="Forum signature"
|
||||
rows={3}
|
||||
error={profileErrors.signature?.[0]}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Short bio / tagline"
|
||||
rows={3}
|
||||
error={profileErrors.description?.[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PERSONAL DETAILS
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-id-card" description="Optional details — only shown if you choose.">
|
||||
Personal Details
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Birthday */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/85 block mb-1.5">Birthday</label>
|
||||
<div className="grid grid-cols-3 gap-3 max-w-md">
|
||||
<Select
|
||||
placeholder="Day"
|
||||
value={day}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
options={buildDayOptions()}
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
options={MONTHS}
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Year"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
options={buildYearOptions()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<RadioGroup
|
||||
label="Gender"
|
||||
name="gender"
|
||||
options={GENDER_OPTIONS}
|
||||
value={gender}
|
||||
onChange={setGender}
|
||||
direction="horizontal"
|
||||
error={profileErrors.gender?.[0]}
|
||||
/>
|
||||
|
||||
{/* Country */}
|
||||
{countryOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
options={countryOptions}
|
||||
placeholder="Select country"
|
||||
error={profileErrors.country?.[0]}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
placeholder="Country code (e.g. US, DE, TR)"
|
||||
error={profileErrors.country?.[0]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PREFERENCES
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-sliders" description="Control emails and sharing behavior.">
|
||||
Preferences
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Mailing List</p>
|
||||
<p className="text-xs text-slate-500">Receive occasional emails about Skinbase news</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={mailing}
|
||||
onChange={(e) => setMailing(e.target.checked)}
|
||||
variant="accent"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/5" />
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Upload Notifications</p>
|
||||
<p className="text-xs text-slate-500">Get notified when people you follow upload new work</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={notify}
|
||||
onChange={(e) => setNotify(e.target.checked)}
|
||||
variant="emerald"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/5" />
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Auto-post Uploads</p>
|
||||
<p className="text-xs text-slate-500">Automatically post to your feed when you publish artwork</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={autoPost}
|
||||
onChange={(e) => setAutoPost(e.target.checked)}
|
||||
variant="sky"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Save Profile Button ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="accent" size="md" loading={saving} onClick={handleProfileSave}>
|
||||
Save Profile
|
||||
</Button>
|
||||
{profileSaved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
|
||||
<svg width="16" height="16" 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>
|
||||
Profile updated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
CHANGE PASSWORD
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section className="mt-4">
|
||||
<SectionTitle icon="fa-solid fa-lock" description="Use a strong, unique password.">
|
||||
Change Password
|
||||
</SectionTitle>
|
||||
|
||||
{passwordErrors._general && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300 mb-4">
|
||||
{passwordErrors._general[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 max-w-md">
|
||||
<TextInput
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
error={passwordErrors.current_password?.[0]}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<TextInput
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
error={passwordErrors.password?.[0]}
|
||||
hint="Minimum 8 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<TextInput
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
error={passwordErrors.password_confirmation?.[0]}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Button variant="secondary" size="md" loading={passwordSaving} onClick={handlePasswordChange}>
|
||||
Update Password
|
||||
</Button>
|
||||
{passwordSaved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
|
||||
<svg width="16" height="16" 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>
|
||||
Password updated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
DANGER ZONE
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section className="border-red-500/20">
|
||||
<SectionTitle icon="fa-solid fa-triangle-exclamation" description="Permanent actions that cannot be undone.">
|
||||
Danger Zone
|
||||
</SectionTitle>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Delete Account</p>
|
||||
<p className="text-xs text-slate-500">Remove your account and all associated data permanently.</p>
|
||||
</div>
|
||||
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* spacer for bottom padding */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
|
||||
{/* ── Delete Confirmation Modal ── */}
|
||||
<Modal
|
||||
open={showDelete}
|
||||
onClose={() => setShowDelete(false)}
|
||||
title="Delete Account"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" loading={deleting} onClick={handleDeleteAccount}>
|
||||
Permanently Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
This action is <span className="text-red-400 font-semibold">irreversible</span>. All your artworks,
|
||||
comments, and profile data will be permanently deleted.
|
||||
</p>
|
||||
<TextInput
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
error={deleteError}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
@@ -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 ·{' '}
|
||||
<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} × {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 · {formatBytes(v.file_size)}</p>
|
||||
)}
|
||||
{v.change_note && (
|
||||
<p className="text-xs text-slate-300 mt-1 italic">“{v.change_note}”</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} × {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
|
||||
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 version—nothing 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,28 @@ export default function EmbeddedArtworkCard({ artwork }) {
|
||||
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
|
||||
const authorUrl = `/@${artwork.author.username}`
|
||||
|
||||
const handleCardClick = (e) => {
|
||||
// Don't navigate when clicking the author link
|
||||
if (e.defaultPrevented) return
|
||||
window.location.href = artUrl
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
window.location.href = artUrl
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={artUrl}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors"
|
||||
title={artwork.title}
|
||||
// Outer element is a div to avoid <a> inside <a> — navigation handled via onClick
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={artwork.title}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
|
||||
@@ -45,7 +62,7 @@ export default function EmbeddedArtworkCard({ artwork }) {
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function slugify(str) {
|
||||
* React version of resources/views/components/artwork-card.blade.php
|
||||
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
|
||||
*/
|
||||
export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) {
|
||||
export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = null }) {
|
||||
const imgRef = useRef(null);
|
||||
const mediaRef = useRef(null);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||
loading={loading}
|
||||
decoding={loading === 'eager' ? 'sync' : 'async'}
|
||||
fetchPriority={fetchpriority || undefined}
|
||||
fetchPriority={fetchPriority || undefined}
|
||||
alt={title}
|
||||
width={hasDimensions ? art.width : undefined}
|
||||
height={hasDimensions ? art.height : undefined}
|
||||
|
||||
@@ -297,7 +297,7 @@ function MasonryGallery({
|
||||
key={`${art.id}-${idx}`}
|
||||
art={art}
|
||||
loading={idx < 8 ? 'eager' : 'lazy'}
|
||||
fetchpriority={idx === 0 ? 'high' : null}
|
||||
fetchPriority={idx === 0 ? 'high' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
181
resources/js/components/ui/MarkdownEditor.jsx
Normal file
181
resources/js/components/ui/MarkdownEditor.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
function ToolbarButton({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({ id, value, onChange, placeholder, error, rows = 5 }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = selected ? start + replacement.length : start + before.length
|
||||
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const lines = (selected || '').split('\n')
|
||||
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + normalized + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + normalized.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>• List</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>❝</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={rows}
|
||||
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<p className="px-3 pb-2 text-[11px] text-white/45">
|
||||
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[132px] px-3 py-2">
|
||||
{String(value || '').trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{String(value || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +1,7 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import React from 'react'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
function ToolbarButton({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownEditor({ id, value, onChange, placeholder, error }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = selected ? start + replacement.length : start + before.length
|
||||
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const lines = (selected || '').split('\n')
|
||||
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + normalized + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + normalized.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
return (
|
||||
<div className={`mt-2 rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>• List</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>❝</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={5}
|
||||
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<p className="px-3 pb-2 text-[11px] text-white/45">
|
||||
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[132px] px-3 py-2">
|
||||
{String(value || '').trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{String(value || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import MarkdownEditor from '../ui/MarkdownEditor'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
|
||||
@@ -388,9 +388,13 @@ export default function useUploadMachine({
|
||||
|
||||
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public' } = opts
|
||||
|
||||
const resolvedCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null
|
||||
|
||||
const buildPayload = () => ({
|
||||
title: String(metadata.title || '').trim() || undefined,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
category: resolvedCategoryId ? String(resolvedCategoryId) : null,
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
mode,
|
||||
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
|
||||
...(timezone ? { timezone } : {}),
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
// - dropdown menus via [data-dropdown]
|
||||
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
|
||||
|
||||
// Alpine.js — powers x-data/x-show/@click in Blade layouts (e.g. cookie banner, toasts).
|
||||
// Guard: don't start a second instance if app.js already loaded Alpine on this page.
|
||||
import Alpine from 'alpinejs';
|
||||
if (!window.Alpine) {
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
}
|
||||
|
||||
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
||||
import './lib/nav-context.js';
|
||||
|
||||
|
||||
18
resources/js/settings.jsx
Normal file
18
resources/js/settings.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
import ProfileEdit from './Pages/Settings/ProfileEdit'
|
||||
|
||||
const pages = {
|
||||
'Settings/ProfileEdit': ProfileEdit,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user