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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user