Files
SkinbaseNova/resources/js/Pages/Settings/ProfileEdit.jsx

1485 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 SuccessMessage({ text, className = '' }) {
if (!text) return null
return (
<div className={`flex items-center gap-2.5 rounded-xl border border-emerald-400/25 bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-300 animate-in fade-in ${className}`}>
<i className="fa-solid fa-circle-check shrink-0 text-emerald-400" />
<span>{text}</span>
</div>
)
}
function ErrorMessage({ text, className = '' }) {
if (!text) return null
return (
<div className={`flex items-center gap-2.5 rounded-xl border border-red-400/25 bg-red-500/10 px-4 py-2.5 text-sm text-red-300 ${className}`}>
<i className="fa-solid fa-circle-exclamation shrink-0 text-red-400" />
<span>{text}</span>
</div>
)
}
function SectionCard({ title, description, icon, children, actionSlot }) {
return (
<section className="rounded-2xl border border-white/[0.06] bg-gradient-to-b from-white/[0.04] to-white/[0.02] p-6 shadow-lg shadow-black/10">
<header className="flex flex-col gap-3 border-b border-white/[0.06] pb-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
{icon ? (
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent">
<i className={`${icon} text-sm`} />
</span>
) : null}
<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>
</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)
}
}
// Auto-dismiss success messages after 4 seconds
useEffect(() => {
if (!savedMessage.text) return
const timer = window.setTimeout(() => {
setSavedMessage({ section: '', text: '' })
}, 4000)
return () => window.clearTimeout(timer)
}, [savedMessage])
const sectionSaved = savedMessage.section === activeSection ? savedMessage.text : ''
return (
<SettingsLayout
title="Settings"
sections={SETTINGS_SECTIONS}
activeSection={activeSection}
onSectionChange={switchSection}
dirtyMap={dirtyMap}
>
<div className="space-y-5">
{activeSection === 'profile' ? (
<form className="space-y-4" onSubmit={saveProfileSection}>
<SectionCard
title="Profile"
icon="fa-solid fa-user-astronaut"
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={`group relative rounded-2xl border border-white/10 bg-black/20 p-5 transition-colors duration-200 ${
dragActive ? 'border-accent/50 bg-accent/5' : ''
}`}
onDragEnter={(e) => {
dragHandler(e)
setDragActive(true)
}}
onDragLeave={(e) => {
dragHandler(e)
setDragActive(false)
}}
onDragOver={dragHandler}
onDrop={handleDrop}
>
<div className="relative mx-auto" style={{ width: 144, height: 144 }}>
<div
className="overflow-hidden rounded-full border-2 border-white/10 bg-white/5 shadow-lg shadow-black/20"
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 flex-col items-center justify-center gap-1 text-slate-500">
<i className="fa-solid fa-camera text-xl" />
<span className="text-[11px]">No avatar</span>
</div>
)}
</div>
{/* Hover overlay */}
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 flex cursor-pointer flex-col items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity duration-200 hover:opacity-100 focus-visible:opacity-100"
aria-label="Upload avatar"
>
<i className="fa-solid fa-camera-retro text-lg" />
<span className="mt-1 text-xs font-medium">Change</span>
</button>
</div>
<p className="mt-3 text-center text-xs text-slate-500">256 × 256 recommended · JPG, PNG, WEBP</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()}
leftIcon={<i className="fa-solid fa-arrow-up-from-bracket text-[10px]" />}
>
Upload
</Button>
{avatarUrl ? (
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => {
setAvatarFile(null)
setRemoveAvatar(true)
setAvatarUrl('')
}}
leftIcon={<i className="fa-solid fa-trash-can text-[10px]" />}
>
Remove
</Button>
) : null}
</div>
</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>
<div className="space-y-4">
<ErrorMessage text={errorsBySection.profile._general?.[0]} />
<SuccessMessage text={sectionSaved} />
<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"
icon="fa-solid fa-id-badge"
description="Update your core account identity details."
actionSlot={
<Button
type="submit"
variant="accent"
size="sm"
loading={savingSection === 'account'}
disabled={usernameCooldownActive}
>
Save Username
</Button>
}
>
<ErrorMessage text={errorsBySection.account._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<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 flex items-center gap-2 rounded-xl 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'
}`}
>
<i className={`fa-solid ${
usernameAvailability.status === 'available' ? 'fa-circle-check' :
usernameAvailability.status === 'checking' ? 'fa-spinner fa-spin' :
'fa-circle-xmark'
} shrink-0`} />
{usernameAvailability.message}
</p>
) : null}
<p className="mt-4 flex items-center gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-xs text-slate-400">
<i className="fa-solid fa-clock shrink-0 text-slate-500" />
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"
icon="fa-solid fa-address-card"
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>
}
>
<ErrorMessage text={errorsBySection.personal._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<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"
icon="fa-solid fa-bell"
description="Choose how and when Skinbase should notify you."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'notifications'}>
Save Notification Settings
</Button>
}
>
<ErrorMessage text={errorsBySection.notifications._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<div className="space-y-2">
{[
['email_notifications', 'Email notifications', 'General email alerts for account activity.', 'fa-solid fa-envelope'],
['upload_notifications', 'Upload notifications', 'Notify me when followed creators upload.', 'fa-solid fa-cloud-arrow-up'],
['follower_notifications', 'Follower notifications', 'Notify me when someone follows me.', 'fa-solid fa-user-plus'],
['comment_notifications', 'Comment notifications', 'Notify me about comments on my content.', 'fa-solid fa-comment-dots'],
['newsletter', 'Newsletter', 'Receive occasional community and product updates.', 'fa-solid fa-newspaper'],
].map(([field, label, hint, icon]) => (
<div key={field} className="flex items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 transition-colors hover:bg-white/[0.04]">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/[0.06] text-slate-400">
<i className={`${icon} text-xs`} />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white/90">{label}</p>
<p className="text-xs text-slate-500">{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"
icon="fa-solid fa-shield-halved"
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>
}
>
<ErrorMessage text={errorsBySection.security._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<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-xl border border-white/[0.06] bg-white/[0.02] p-4">
<p className="flex items-center gap-2 text-xs text-slate-400">
<i className="fa-solid fa-lock text-slate-500" />
Coming soon: Two-factor authentication, active sessions, and login history.
</p>
</div>
{renderCaptchaChallenge('security')}
</div>
</SectionCard>
</form>
) : null}
{activeSection === 'danger' ? (
<section className="rounded-2xl border border-red-500/20 bg-gradient-to-b from-red-500/[0.06] to-red-500/[0.02] p-6 shadow-lg shadow-red-900/10">
<header className="flex flex-col gap-3 border-b border-red-500/10 pb-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-red-500/15 text-red-400">
<i className="fa-solid fa-triangle-exclamation text-sm" />
</span>
<div>
<h2 className="text-base font-semibold text-red-300">Danger Zone</h2>
<p className="mt-1 text-sm text-red-400/70">These actions are permanent and cannot be undone.</p>
</div>
</div>
</header>
<div className="pt-5">
<div className="rounded-xl border border-red-500/15 bg-red-500/[0.04] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-white">Delete account</p>
<p className="mt-0.5 text-xs text-slate-400">
Permanently removes your artworks, comments, followers, and all profile data.
</p>
</div>
<Button variant="danger" size="sm" onClick={() => setShowDeleteModal(true)}>
<i className="fa-solid fa-trash-can mr-1.5 text-xs" />
Delete Account
</Button>
</div>
</div>
</div>
</section>
) : 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>
)
}