Files
SkinbaseNova/resources/js/Pages/Settings/ProfileEdit.jsx
2026-03-20 21:17:26 +01:00

1453 lines
53 KiB
JavaScript

import React, { useEffect, useMemo, useRef, useState } 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 NovaSelect from '../../components/ui/NovaSelect'
import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio'
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
import TurnstileField from '../../components/security/TurnstileField'
const SETTINGS_SECTIONS = [
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
{ key: 'account', label: 'Account', icon: 'fa-solid fa-id-badge', description: 'Username and email address.' },
{ key: 'personal', label: 'Personal', icon: 'fa-solid fa-address-card', description: 'Optional personal information.' },
{ key: 'notifications', label: 'Notifications', icon: 'fa-solid fa-bell', description: 'Manage notification preferences.' },
{ key: 'security', label: 'Security', icon: 'fa-solid fa-shield-halved', description: 'Password and account security.' },
{ key: 'danger', label: 'Danger Zone', icon: 'fa-solid fa-triangle-exclamation', description: 'Destructive account actions.' },
]
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' },
]
const AVATAR_POSITION_OPTIONS = [
{ value: 'top-left', label: 'Top Left' },
{ value: 'top', label: 'Top' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom', label: 'Bottom' },
{ value: 'bottom-right', label: 'Bottom Right' },
]
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function botHeaders(extra = {}, captcha = {}) {
const fingerprint = await buildBotFingerprint()
return {
...extra,
'X-Bot-Fingerprint': fingerprint,
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
}
}
function toIsoDate(day, month, year) {
if (!day || !month || !year) return ''
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
function fromIsoDate(iso) {
if (!iso || typeof iso !== 'string' || !iso.includes('-')) {
return { day: '', month: '', year: '' }
}
const [year, month, day] = iso.split('-')
return {
day: String(parseInt(day || '', 10) || ''),
month: String(parseInt(month || '', 10) || ''),
year: String(year || ''),
}
}
function equalsObject(a, b) {
return JSON.stringify(a) === JSON.stringify(b)
}
function positionToObjectPosition(position) {
const map = {
'top-left': '0% 0%',
top: '50% 0%',
'top-right': '100% 0%',
left: '0% 50%',
center: '50% 50%',
right: '100% 50%',
'bottom-left': '0% 100%',
bottom: '50% 100%',
'bottom-right': '100% 100%',
}
return map[position] || '50% 50%'
}
function SectionCard({ title, description, children, actionSlot }) {
return (
<section className="rounded-xl border border-white/5 bg-white/[0.03] p-6">
<header className="flex flex-col gap-3 border-b border-white/5 pb-4 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
{description ? <p className="mt-1 text-sm text-slate-400">{description}</p> : null}
</div>
{actionSlot ? <div>{actionSlot}</div> : null}
</header>
<div className="pt-5">{children}</div>
</section>
)
}
export default function ProfileEdit() {
const { props } = usePage()
const {
user,
avatarUrl: initialAvatarUrl,
birthDay,
birthMonth,
birthYear,
countries = [],
usernameCooldownDays = 30,
usernameCooldownRemainingDays = 0,
usernameCooldownActive = false,
captcha: initialCaptcha = {},
flash = {},
} = props
const fallbackDate = toIsoDate(
birthDay ? parseInt(String(birthDay), 10) : '',
birthMonth ? parseInt(String(birthMonth), 10) : '',
birthYear ? String(birthYear) : '',
)
const [activeSection, setActiveSection] = useState('profile')
const [profileForm, setProfileForm] = useState({
display_name: user?.name || '',
website: user?.homepage || '',
bio: user?.about_me || '',
signature: user?.signature || '',
description: user?.description || '',
})
const [accountForm, setAccountForm] = useState({
username: user?.username || '',
email: user?.email || '',
})
const [usernameAvailability, setUsernameAvailability] = useState({
status: 'idle',
message: '',
})
const [showEmailChangeModal, setShowEmailChangeModal] = useState(false)
const [emailChangeStep, setEmailChangeStep] = useState('request')
const [emailChangeLoading, setEmailChangeLoading] = useState(false)
const [emailChangeForm, setEmailChangeForm] = useState({
new_email: '',
code: '',
})
const [emailChangeError, setEmailChangeError] = useState('')
const [emailChangeInfo, setEmailChangeInfo] = useState('')
const [personalForm, setPersonalForm] = useState(() => {
const fromUser = fromIsoDate(user?.birthday || '')
const fromProps = {
day: birthDay ? String(parseInt(String(birthDay), 10) || '') : '',
month: birthMonth ? String(parseInt(String(birthMonth), 10) || '') : '',
year: birthYear ? String(birthYear) : '',
}
return {
day: fromUser.day || fromProps.day || '',
month: fromUser.month || fromProps.month || '',
year: fromUser.year || fromProps.year || '',
gender: String(user?.gender || '').toLowerCase() || '',
country_id: user?.country_id ? String(user.country_id) : '',
}
})
const [notificationForm, setNotificationForm] = useState({
email_notifications: !!user?.email_notifications,
upload_notifications: !!user?.upload_notifications,
follower_notifications: !!user?.follower_notifications,
comment_notifications: !!user?.comment_notifications,
newsletter: !!user?.newsletter,
})
const [securityForm, setSecurityForm] = useState({
current_password: '',
new_password: '',
new_password_confirmation: '',
})
const [savingSection, setSavingSection] = useState('')
const [savedMessage, setSavedMessage] = useState({ section: '', text: '' })
const [errorsBySection, setErrorsBySection] = useState({
profile: {},
account: {},
personal: {},
notifications: {},
security: {},
})
const [captchaState, setCaptchaState] = useState({
required: !!flash?.botCaptchaRequired,
section: '',
token: '',
message: '',
nonce: 0,
provider: initialCaptcha?.provider || '',
siteKey: initialCaptcha?.siteKey || '',
inputName: initialCaptcha?.inputName || 'cf-turnstile-response',
scriptUrl: initialCaptcha?.scriptUrl || '',
})
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
const [avatarFile, setAvatarFile] = useState(null)
const [avatarPosition, setAvatarPosition] = useState('center')
const [dragActive, setDragActive] = useState(false)
const [removeAvatar, setRemoveAvatar] = useState(false)
const avatarInputRef = useRef(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deletePassword, setDeletePassword] = useState('')
const [deleteError, setDeleteError] = useState('')
const [deleting, setDeleting] = useState(false)
const initialRef = useRef({
profileForm,
accountForm,
personalForm,
notificationForm,
avatarUrl: initialAvatarUrl || '',
})
const dirtyMap = useMemo(() => {
return {
profile:
!equalsObject(profileForm, initialRef.current.profileForm) ||
!!avatarFile ||
(avatarFile && avatarPosition !== 'center') ||
removeAvatar ||
avatarUrl !== initialRef.current.avatarUrl,
account: !equalsObject(accountForm, initialRef.current.accountForm),
personal: !equalsObject(personalForm, initialRef.current.personalForm),
notifications: !equalsObject(notificationForm, initialRef.current.notificationForm),
security:
!!securityForm.current_password ||
!!securityForm.new_password ||
!!securityForm.new_password_confirmation,
danger: false,
}
}, [profileForm, accountForm, personalForm, notificationForm, securityForm, avatarFile, avatarPosition, removeAvatar, avatarUrl])
const hasUnsavedChanges = useMemo(
() => Object.entries(dirtyMap).some(([key, dirty]) => key !== 'danger' && dirty),
[dirtyMap],
)
useEffect(() => {
const beforeUnload = (event) => {
if (!hasUnsavedChanges) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', beforeUnload)
return () => window.removeEventListener('beforeunload', beforeUnload)
}, [hasUnsavedChanges])
useEffect(() => {
if (usernameCooldownActive) {
setUsernameAvailability({ status: 'idle', message: '' })
return
}
const candidate = String(accountForm.username || '').trim().toLowerCase()
const current = String(initialRef.current.accountForm.username || '').trim().toLowerCase()
if (!candidate || candidate === current) {
setUsernameAvailability({ status: 'idle', message: '' })
return
}
if (!USERNAME_REGEX.test(candidate)) {
setUsernameAvailability({ status: 'invalid', message: 'Use 3-20 letters, numbers, or underscores.' })
return
}
setUsernameAvailability({ status: 'checking', message: 'Checking availability...' })
const controller = new AbortController()
const timeout = window.setTimeout(async () => {
try {
const response = await fetch(`/api/username/check?username=${encodeURIComponent(candidate)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
signal: controller.signal,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const msg = payload?.errors?.username?.[0] || 'Username is not available.'
setUsernameAvailability({ status: 'invalid', message: msg })
return
}
if (payload.available) {
setUsernameAvailability({ status: 'available', message: 'Username is available.' })
return
}
setUsernameAvailability({ status: 'taken', message: 'Username is already taken.' })
} catch (error) {
if (error?.name !== 'AbortError') {
setUsernameAvailability({ status: 'invalid', message: 'Unable to check username right now.' })
}
}
}, 300)
return () => {
controller.abort()
window.clearTimeout(timeout)
}
}, [accountForm.username, usernameCooldownActive])
const openEmailChangeModal = () => {
setShowEmailChangeModal(true)
setEmailChangeStep('request')
setEmailChangeError('')
setEmailChangeInfo('')
setEmailChangeForm({ new_email: '', code: '' })
}
const closeEmailChangeModal = () => {
if (emailChangeLoading) return
setShowEmailChangeModal(false)
setEmailChangeStep('request')
setEmailChangeError('')
setEmailChangeInfo('')
setEmailChangeForm({ new_email: '', code: '' })
}
const countryOptions = (countries || []).map((c) => ({
value: String(c.id || ''),
label: c.name || '',
iso2: c.iso2 || '',
flagEmoji: c.flag_emoji || '',
flagPath: c.flag_path || '',
group: c.is_featured ? 'Featured' : 'All countries',
}))
const yearOptions = useMemo(() => {
const current = new Date().getFullYear()
return Array.from({ length: 100 }, (_, index) => {
const year = String(current - index)
return { value: year, label: year }
})
}, [])
const dayOptions = useMemo(
() => Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) })),
[],
)
const clearSectionStatus = (section) => {
setSavedMessage((prev) => (prev.section === section ? { section: '', text: '' } : prev))
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
}
const resetCaptchaState = () => {
setCaptchaState((prev) => ({
...prev,
required: false,
section: '',
token: '',
message: '',
nonce: prev.nonce + 1,
}))
}
const captureCaptchaRequirement = (section, payload = {}) => {
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha)
if (!requiresCaptcha) {
return false
}
const nextCaptcha = payload?.captcha || {}
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.'
setCaptchaState((prev) => ({
required: true,
section,
token: '',
message,
nonce: prev.nonce + 1,
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile',
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '',
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response',
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '',
}))
updateSectionErrors(section, {
_general: [message],
captcha: [message],
})
return true
}
const applyCaptchaPayload = (payload = {}) => {
if (!captchaState.required || !captchaState.inputName) {
return payload
}
return {
...payload,
[captchaState.inputName]: captchaState.token || '',
}
}
const applyCaptchaFormData = (formData) => {
if (captchaState.required && captchaState.inputName) {
formData.set(captchaState.inputName, captchaState.token || '')
}
}
const renderCaptchaChallenge = (section, placement = 'section') => {
if (!captchaState.required || !captchaState.siteKey || activeSection !== section) {
return null
}
if (section === 'account' && showEmailChangeModal && placement !== 'modal') {
return null
}
if (section === 'account' && !showEmailChangeModal && placement === 'modal') {
return null
}
return (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="mb-3 text-sm text-amber-100">{captchaState.message || 'Complete the captcha challenge to continue.'}</p>
<TurnstileField
key={`${section}-${placement}-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
</div>
)
}
const switchSection = (nextSection) => {
if (activeSection === nextSection) return
if (dirtyMap[activeSection]) {
const shouldContinue = window.confirm('You have unsaved changes in this section. Leave without saving?')
if (!shouldContinue) return
}
setActiveSection(nextSection)
}
const handleAvatarSelect = (file) => {
if (!file) return
if (!file.type.startsWith('image/')) return
setAvatarFile(file)
setRemoveAvatar(false)
setAvatarUrl(URL.createObjectURL(file))
clearSectionStatus('profile')
}
const dragHandler = (event) => {
event.preventDefault()
event.stopPropagation()
}
const handleDrop = (event) => {
dragHandler(event)
setDragActive(false)
const file = event.dataTransfer?.files?.[0]
if (file) handleAvatarSelect(file)
}
const updateSectionErrors = (section, errors = {}) => {
setErrorsBySection((prev) => ({ ...prev, [section]: errors }))
}
const saveProfileSection = async (event) => {
event.preventDefault()
setSavingSection('profile')
clearSectionStatus('profile')
try {
const formData = new FormData()
formData.append('display_name', profileForm.display_name || '')
formData.append('website', profileForm.website || '')
formData.append('bio', profileForm.bio || '')
formData.append('signature', profileForm.signature || '')
formData.append('description', profileForm.description || '')
formData.append('remove_avatar', removeAvatar ? '1' : '0')
formData.append('avatar_position', avatarPosition)
if (avatarFile) {
formData.append('avatar', avatarFile)
}
applyCaptchaFormData(formData)
const response = await fetch('/settings/profile/update', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: formData,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('profile', payload)) {
return
}
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
return
}
const nextAvatarUrl = payload.avatarUrl || avatarUrl || initialAvatarUrl
initialRef.current.profileForm = { ...profileForm }
initialRef.current.avatarUrl = nextAvatarUrl || ''
setAvatarUrl(nextAvatarUrl || '')
setAvatarFile(null)
setAvatarPosition('center')
setRemoveAvatar(false)
resetCaptchaState()
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
} catch (error) {
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
} finally {
setSavingSection('')
}
}
const saveAccountSection = async (event) => {
event.preventDefault()
if (usernameCooldownActive && accountForm.username !== initialRef.current.accountForm.username) {
updateSectionErrors('account', {
username: [`Username can be changed again in ${usernameCooldownRemainingDays} days.`],
})
return
}
setSavingSection('account')
clearSectionStatus('account')
try {
const response = await fetch('/settings/account/username', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
return
}
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
return
}
initialRef.current.accountForm = { ...accountForm }
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
} catch (error) {
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
} finally {
setSavingSection('')
}
}
const requestEmailChange = async () => {
setEmailChangeLoading(true)
setEmailChangeError('')
setEmailChangeInfo('')
try {
const response = await fetch('/settings/email/request', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
return
}
setEmailChangeStep('verify')
resetCaptchaState()
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
} catch (error) {
setEmailChangeError('Request failed. Please try again.')
} finally {
setEmailChangeLoading(false)
}
}
const verifyEmailChange = async () => {
setEmailChangeLoading(true)
setEmailChangeError('')
setEmailChangeInfo('')
try {
const response = await fetch('/settings/email/verify', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
return
}
const nextEmail = payload.email || emailChangeForm.new_email
setAccountForm((prev) => ({ ...prev, email: nextEmail }))
initialRef.current.accountForm = { ...initialRef.current.accountForm, email: nextEmail }
setShowEmailChangeModal(false)
setEmailChangeStep('request')
setEmailChangeForm({ new_email: '', code: '' })
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
} catch (error) {
setEmailChangeError('Request failed. Please try again.')
} finally {
setEmailChangeLoading(false)
}
}
const savePersonalSection = async (event) => {
event.preventDefault()
setSavingSection('personal')
clearSectionStatus('personal')
try {
const response = await fetch('/settings/personal/update', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
gender: personalForm.gender || null,
country_id: personalForm.country_id || null,
homepage_url: '',
})),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('personal', payload)) {
return
}
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
return
}
initialRef.current.personalForm = { ...personalForm }
resetCaptchaState()
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
} catch (error) {
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
} finally {
setSavingSection('')
}
}
const saveNotificationsSection = async (event) => {
event.preventDefault()
setSavingSection('notifications')
clearSectionStatus('notifications')
try {
const response = await fetch('/settings/notifications/update', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('notifications', payload)) {
return
}
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
return
}
initialRef.current.notificationForm = { ...notificationForm }
resetCaptchaState()
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
} catch (error) {
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
} finally {
setSavingSection('')
}
}
const saveSecuritySection = async (event) => {
event.preventDefault()
setSavingSection('security')
clearSectionStatus('security')
try {
const response = await fetch('/settings/security/password', {
method: 'POST',
credentials: 'same-origin',
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('security', payload)) {
return
}
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
return
}
setSecurityForm({
current_password: '',
new_password: '',
new_password_confirmation: '',
})
resetCaptchaState()
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
} catch (error) {
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
} finally {
setSavingSection('')
}
}
const handleDeleteAccount = async () => {
setDeleting(true)
setDeleteError('')
try {
const response = await fetch('/profile', {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ password: deletePassword }),
})
if (response.ok || response.status === 302) {
window.location.href = '/'
return
}
const payload = await response.json().catch(() => ({}))
setDeleteError(payload.errors?.password?.[0] || payload.message || 'Deletion failed.')
} catch (error) {
setDeleteError('Request failed.')
} finally {
setDeleting(false)
}
}
const sectionSaved = savedMessage.section === activeSection ? savedMessage.text : ''
return (
<SettingsLayout
title="Settings"
sections={SETTINGS_SECTIONS}
activeSection={activeSection}
onSectionChange={switchSection}
>
<div className="space-y-4">
<div className="flex flex-col gap-2 rounded-xl border border-white/5 bg-white/[0.02] p-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-slate-300">
Configure your account by section. Each card saves independently.
</p>
{dirtyMap[activeSection] ? (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-medium text-amber-300">
Unsaved changes
</span>
) : null}
</div>
{activeSection === 'profile' ? (
<form className="space-y-4" onSubmit={saveProfileSection}>
<SectionCard
title="Profile"
description="Manage your public identity and profile presentation."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'profile'}>
Save Profile
</Button>
}
>
<div className="grid gap-6 lg:grid-cols-[260px,1fr]">
<div className="space-y-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div
className="mx-auto overflow-hidden rounded-full border border-white/15 bg-white/5"
style={{ width: 144, height: 144, minWidth: 144, minHeight: 144 }}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar preview"
className="block h-full w-full object-cover object-center"
style={{ aspectRatio: '1 / 1', objectPosition: positionToObjectPosition(avatarPosition) }}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-500">No avatar</div>
)}
</div>
<p className="mt-3 text-center text-xs text-slate-400">Recommended size: 256 x 256</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<Select
label="Avatar crop position"
value={avatarPosition}
onChange={(e) => {
setAvatarPosition(e.target.value)
clearSectionStatus('profile')
}}
options={AVATAR_POSITION_OPTIONS}
hint="Applies when saving a newly selected avatar"
/>
</div>
<div
className={`rounded-xl border-2 border-dashed p-4 text-center transition ${
dragActive ? 'border-accent/60 bg-accent/10' : 'border-white/15 bg-white/[0.02]'
}`}
onDragEnter={(e) => {
dragHandler(e)
setDragActive(true)
}}
onDragLeave={(e) => {
dragHandler(e)
setDragActive(false)
}}
onDragOver={dragHandler}
onDrop={handleDrop}
>
<p className="text-sm text-white">Drag and drop avatar here</p>
<p className="mt-1 text-xs text-slate-400">JPG, PNG, WEBP up to 2 MB</p>
<input
ref={avatarInputRef}
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
/>
<div className="mt-3 flex items-center justify-center gap-2">
<Button
type="button"
size="xs"
variant="secondary"
onClick={() => avatarInputRef.current?.click()}
>
Upload avatar
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => {
setAvatarFile(null)
setRemoveAvatar(true)
setAvatarUrl('')
}}
>
Remove avatar
</Button>
</div>
</div>
</div>
<div className="space-y-4">
{errorsBySection.profile._general ? (
<div className="rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.profile._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<TextInput
label="Display Name"
value={profileForm.display_name}
onChange={(e) => {
setProfileForm((prev) => ({ ...prev, display_name: e.target.value }))
clearSectionStatus('profile')
}}
error={errorsBySection.profile.display_name?.[0]}
maxLength={60}
required
/>
<TextInput
label="Website"
value={profileForm.website}
onChange={(e) => {
setProfileForm((prev) => ({ ...prev, website: e.target.value }))
clearSectionStatus('profile')
}}
placeholder="https://"
error={errorsBySection.profile.website?.[0]}
/>
<Textarea
label="Bio"
value={profileForm.bio}
onChange={(e) => {
setProfileForm((prev) => ({ ...prev, bio: e.target.value }))
clearSectionStatus('profile')
}}
rows={4}
maxLength={200}
error={errorsBySection.profile.bio?.[0]}
hint={`${(profileForm.bio || '').length}/200`}
/>
<Textarea
label="Signature"
value={profileForm.signature}
onChange={(e) => {
setProfileForm((prev) => ({ ...prev, signature: e.target.value }))
clearSectionStatus('profile')
}}
rows={3}
error={errorsBySection.profile.signature?.[0]}
/>
<Textarea
label="Description"
value={profileForm.description}
onChange={(e) => {
setProfileForm((prev) => ({ ...prev, description: e.target.value }))
clearSectionStatus('profile')
}}
rows={3}
error={errorsBySection.profile.description?.[0]}
/>
{renderCaptchaChallenge('profile')}
</div>
</div>
</SectionCard>
</form>
) : null}
{activeSection === 'account' ? (
<form className="space-y-4" onSubmit={saveAccountSection}>
<SectionCard
title="Account"
description="Update your core account identity details."
actionSlot={
<Button
type="submit"
variant="accent"
size="sm"
loading={savingSection === 'account'}
disabled={usernameCooldownActive}
>
Save Username
</Button>
}
>
{errorsBySection.account._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.account._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<TextInput
label="Username"
value={accountForm.username}
onChange={(e) => {
setAccountForm((prev) => ({ ...prev, username: e.target.value }))
clearSectionStatus('account')
}}
disabled={usernameCooldownActive}
error={errorsBySection.account.username?.[0]}
hint={usernameCooldownActive ? `Username can be changed again in ${usernameCooldownRemainingDays} days.` : 'Allowed: letters, numbers, underscores (3-20).'}
required
/>
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Current Email</p>
<p className="mt-1 text-sm font-medium text-white">{accountForm.email || 'No email set'}</p>
<div className="mt-3">
<Button type="button" variant="secondary" size="sm" onClick={openEmailChangeModal}>
Change Email
</Button>
</div>
</div>
</div>
{usernameAvailability.status !== 'idle' ? (
<p
className={`mt-4 rounded-lg border px-3 py-2 text-xs ${
usernameAvailability.status === 'available'
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300'
: usernameAvailability.status === 'checking'
? 'border-sky-400/30 bg-sky-500/10 text-sky-300'
: 'border-red-400/30 bg-red-500/10 text-red-300'
}`}
>
{usernameAvailability.message}
</p>
) : null}
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
You can change your username once every {usernameCooldownDays} days.
</p>
{renderCaptchaChallenge('account')}
</SectionCard>
</form>
) : null}
{activeSection === 'personal' ? (
<form className="space-y-4" onSubmit={savePersonalSection}>
<SectionCard
title="Personal Details"
description="Optional information shown only when you decide to provide it."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'personal'}>
Save Personal Details
</Button>
}
>
{errorsBySection.personal._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.personal._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-white/85">Birthday</label>
<div className="grid max-w-lg grid-cols-3 gap-3">
<Select
placeholder="Day"
value={personalForm.day}
onChange={(e) => {
setPersonalForm((prev) => ({ ...prev, day: e.target.value }))
clearSectionStatus('personal')
}}
options={dayOptions}
/>
<Select
placeholder="Month"
value={personalForm.month}
onChange={(e) => {
setPersonalForm((prev) => ({ ...prev, month: e.target.value }))
clearSectionStatus('personal')
}}
options={MONTHS}
/>
<Select
placeholder="Year"
value={personalForm.year}
onChange={(e) => {
setPersonalForm((prev) => ({ ...prev, year: e.target.value }))
clearSectionStatus('personal')
}}
options={yearOptions}
/>
</div>
{errorsBySection.personal.birthday?.[0] ? (
<p className="mt-1 text-xs text-red-300">{errorsBySection.personal.birthday[0]}</p>
) : null}
</div>
<RadioGroup
label="Gender"
name="gender"
options={GENDER_OPTIONS}
value={personalForm.gender}
onChange={(value) => {
setPersonalForm((prev) => ({ ...prev, gender: value }))
clearSectionStatus('personal')
}}
direction="horizontal"
error={errorsBySection.personal.gender?.[0]}
/>
{countryOptions.length > 0 ? (
<NovaSelect
label="Country"
value={personalForm.country_id || null}
onChange={(value) => {
setPersonalForm((prev) => ({ ...prev, country_id: value ? String(value) : '' }))
clearSectionStatus('personal')
}}
options={countryOptions}
placeholder="Choose country"
clearable
error={errorsBySection.personal.country_id?.[0] || errorsBySection.personal.country?.[0]}
hint="Search by country name or ISO code."
renderOption={(option) => (
<span className="flex min-w-0 items-center gap-2">
{option.flagPath ? (
<img
src={option.flagPath}
alt=""
className="h-4 w-6 rounded-sm object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
/>
) : option.flagEmoji ? (
<span>{option.flagEmoji}</span>
) : null}
<span className="truncate">{option.label}</span>
{option.iso2 ? <span className="shrink-0 text-[11px] uppercase text-slate-500">{option.iso2}</span> : null}
</span>
)}
/>
) : (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-400">
Country list is currently unavailable.
</div>
)}
{renderCaptchaChallenge('personal')}
</div>
</SectionCard>
</form>
) : null}
{activeSection === 'notifications' ? (
<form className="space-y-4" onSubmit={saveNotificationsSection}>
<SectionCard
title="Notifications"
description="Choose how and when Skinbase should notify you."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'notifications'}>
Save Notification Settings
</Button>
}
>
{errorsBySection.notifications._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.notifications._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<div className="space-y-3">
{[
['email_notifications', 'Email notifications', 'General email alerts for account activity.'],
['upload_notifications', 'Upload notifications', 'Notify me when followed creators upload.'],
['follower_notifications', 'Follower notifications', 'Notify me when someone follows me.'],
['comment_notifications', 'Comment notifications', 'Notify me about comments on my content.'],
['newsletter', 'Newsletter', 'Receive occasional community and product updates.'],
].map(([field, label, hint]) => (
<div key={field} className="flex items-center justify-between rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
<div>
<p className="text-sm font-medium text-white/90">{label}</p>
<p className="text-xs text-slate-400">{hint}</p>
</div>
<Toggle
checked={!!notificationForm[field]}
onChange={(e) => {
setNotificationForm((prev) => ({ ...prev, [field]: e.target.checked }))
clearSectionStatus('notifications')
}}
variant="accent"
/>
</div>
))}
{renderCaptchaChallenge('notifications')}
</div>
</SectionCard>
</form>
) : null}
{activeSection === 'security' ? (
<form className="space-y-4" onSubmit={saveSecuritySection}>
<SectionCard
title="Security"
description="Update password. Additional security controls can be added here later."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'security'}>
Update Password
</Button>
}
>
{errorsBySection.security._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.security._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<div className="grid max-w-2xl gap-4">
<TextInput
label="Current Password"
type="password"
value={securityForm.current_password}
onChange={(e) => {
setSecurityForm((prev) => ({ ...prev, current_password: e.target.value }))
clearSectionStatus('security')
}}
error={errorsBySection.security.current_password?.[0]}
autoComplete="current-password"
/>
<TextInput
label="New Password"
type="password"
value={securityForm.new_password}
onChange={(e) => {
setSecurityForm((prev) => ({ ...prev, new_password: e.target.value }))
clearSectionStatus('security')
}}
error={errorsBySection.security.new_password?.[0]}
hint="Minimum 8 characters"
autoComplete="new-password"
/>
<TextInput
label="Confirm Password"
type="password"
value={securityForm.new_password_confirmation}
onChange={(e) => {
setSecurityForm((prev) => ({ ...prev, new_password_confirmation: e.target.value }))
clearSectionStatus('security')
}}
error={errorsBySection.security.new_password_confirmation?.[0]}
autoComplete="new-password"
/>
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
Future security controls: Two-factor authentication, active sessions, and login history.
</div>
{renderCaptchaChallenge('security')}
</div>
</SectionCard>
</form>
) : null}
{activeSection === 'danger' ? (
<SectionCard
title="Danger Zone"
description="This action cannot be undone."
actionSlot={
<Button variant="danger" size="sm" onClick={() => setShowDeleteModal(true)}>
Delete Account
</Button>
}
>
<p className="text-sm text-slate-300">
Deleting your account permanently removes your artworks, comments, and profile data.
</p>
</SectionCard>
) : null}
</div>
<Modal
open={showEmailChangeModal}
onClose={closeEmailChangeModal}
title="Change Email"
size="sm"
footer={
<div className="ml-auto flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={closeEmailChangeModal} disabled={emailChangeLoading}>
Cancel
</Button>
{emailChangeStep === 'request' ? (
<Button
type="button"
variant="accent"
size="sm"
loading={emailChangeLoading}
onClick={requestEmailChange}
>
Send Code
</Button>
) : (
<Button
type="button"
variant="accent"
size="sm"
loading={emailChangeLoading}
onClick={verifyEmailChange}
>
Verify and Update
</Button>
)}
</div>
}
>
<div className="space-y-4">
{emailChangeError ? (
<div className="rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{emailChangeError}
</div>
) : null}
{emailChangeInfo ? (
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{emailChangeInfo}
</div>
) : null}
{renderCaptchaChallenge('account', 'modal')}
{emailChangeStep === 'request' ? (
<TextInput
label="Enter new email address"
type="email"
value={emailChangeForm.new_email}
onChange={(e) => setEmailChangeForm((prev) => ({ ...prev, new_email: e.target.value }))}
autoComplete="email"
required
/>
) : (
<>
<p className="text-sm text-slate-300">Enter the 6-digit verification code sent to {emailChangeForm.new_email}.</p>
<TextInput
label="Verification Code"
value={emailChangeForm.code}
onChange={(e) => setEmailChangeForm((prev) => ({ ...prev, code: e.target.value }))}
inputMode="numeric"
maxLength={6}
required
/>
</>
)}
</div>
</Modal>
<Modal
open={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Delete Account"
size="sm"
footer={
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setShowDeleteModal(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">Confirm your password to permanently delete your account.</p>
<TextInput
label="Password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
error={deleteError}
autoComplete="current-password"
/>
</div>
</Modal>
</SettingsLayout>
)
}