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 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 (

{title}

{description ?

{description}

: null}
{actionSlot ?
{actionSlot}
: null}
{children}
) } 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: user?.country_code || '', } }) 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: c.country_code || c.code || c.id || '', label: c.country_name || c.name || '', })) 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 (

{captchaState.message || 'Complete the captcha challenge to continue.'}

setCaptchaState((prev) => ({ ...prev, token }))} className="rounded-lg border border-white/10 bg-black/20 p-3" />
) } 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: personalForm.country || 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 (

Configure your account by section. Each card saves independently.

{dirtyMap[activeSection] ? ( Unsaved changes ) : null}
{activeSection === 'profile' ? (
Save Profile } >
{avatarUrl ? ( Avatar preview ) : (
No avatar
)}

Recommended size: 256 x 256

handleAvatarSelect(e.target.files?.[0])} />
{errorsBySection.profile._general ? (
{errorsBySection.profile._general[0]}
) : null} {sectionSaved ? (
{sectionSaved}
) : null} { setProfileForm((prev) => ({ ...prev, display_name: e.target.value })) clearSectionStatus('profile') }} error={errorsBySection.profile.display_name?.[0]} maxLength={60} required /> { setProfileForm((prev) => ({ ...prev, website: e.target.value })) clearSectionStatus('profile') }} placeholder="https://" error={errorsBySection.profile.website?.[0]} />