1485 lines
56 KiB
JavaScript
1485 lines
56 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 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>
|
||
)
|
||
}
|