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

@@ -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 &amp; 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>
)
}