import React, { useState, useRef, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import SettingsLayout from '../../Layouts/SettingsLayout'
import TextInput from '../../components/ui/TextInput'
import Textarea from '../../components/ui/Textarea'
import Button from '../../components/ui/Button'
import Toggle from '../../components/ui/Toggle'
import Select from '../../components/ui/Select'
import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const MONTHS = [
{ value: '1', label: 'January' }, { value: '2', label: 'February' },
{ value: '3', label: 'March' }, { value: '4', label: 'April' },
{ value: '5', label: 'May' }, { value: '6', label: 'June' },
{ value: '7', label: 'July' }, { value: '8', label: 'August' },
{ value: '9', label: 'September' }, { value: '10', label: 'October' },
{ value: '11', label: 'November' }, { value: '12', label: 'December' },
]
const GENDER_OPTIONS = [
{ value: 'm', label: 'Male' },
{ value: 'f', label: 'Female' },
{ value: 'x', label: 'Prefer not to say' },
]
function buildDayOptions() {
return Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))
}
function buildYearOptions() {
const currentYear = new Date().getFullYear()
const years = []
for (let y = currentYear; y >= currentYear - 100; y--) {
years.push({ value: String(y), label: String(y) })
}
return years
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function Section({ children, className = '' }) {
return (
)
}
function SectionTitle({ icon, children, description }) {
return (
{icon && }
{children}
{description &&
{description}
}
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function ProfileEdit() {
const { props } = usePage()
const {
user,
avatarUrl: initialAvatarUrl,
birthDay: initDay,
birthMonth: initMonth,
birthYear: initYear,
countries = [],
flash = {},
} = props
// ── Profile State ──────────────────────────────────────────────────────────
const [name, setName] = useState(user?.name || '')
const [email, setEmail] = useState(user?.email || '')
const [username, setUsername] = useState(user?.username || '')
const [homepage, setHomepage] = useState(user?.homepage || user?.website || '')
const [about, setAbout] = useState(user?.about_me || user?.about || '')
const [signature, setSignature] = useState(user?.signature || '')
const [description, setDescription] = useState(user?.description || '')
const [day, setDay] = useState(initDay ? String(parseInt(initDay, 10)) : '')
const [month, setMonth] = useState(initMonth ? String(parseInt(initMonth, 10)) : '')
const [year, setYear] = useState(initYear ? String(initYear) : '')
const [gender, setGender] = useState(() => {
const g = (user?.gender || '').toLowerCase()
if (g === 'm') return 'm'
if (g === 'f') return 'f'
if (g === 'x' || g === 'n') return 'x'
return ''
})
const [country, setCountry] = useState(user?.country_code || user?.country || '')
const [mailing, setMailing] = useState(!!user?.mlist)
const [notify, setNotify] = useState(!!user?.friend_upload_notice)
const [autoPost, setAutoPost] = useState(!!user?.auto_post_upload)
// Avatar
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
const [avatarFile, setAvatarFile] = useState(null)
const [avatarUploading, setAvatarUploading] = useState(false)
const avatarInputRef = useRef(null)
const [dragActive, setDragActive] = useState(false)
// Save state
const [saving, setSaving] = useState(false)
const [profileErrors, setProfileErrors] = useState({})
const [profileSaved, setProfileSaved] = useState(!!flash?.status)
// Password state
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordErrors, setPasswordErrors] = useState({})
const [passwordSaved, setPasswordSaved] = useState(false)
// Delete account
const [showDelete, setShowDelete] = useState(false)
const [deletePassword, setDeletePassword] = useState('')
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
// ── Country Options ─────────────────────────────────────────────────────────
const countryOptions = (countries || []).map((c) => ({
value: c.country_code || c.code || c.id || '',
label: c.country_name || c.name || '',
}))
// ── Avatar Handlers ────────────────────────────────────────────────────────
const handleAvatarSelect = (file) => {
if (!file || !file.type.startsWith('image/')) return
setAvatarFile(file)
setAvatarUrl(URL.createObjectURL(file))
}
const handleAvatarUpload = useCallback(async () => {
if (!avatarFile) return
setAvatarUploading(true)
try {
const fd = new FormData()
fd.append('avatar', avatarFile)
const res = await fetch('/avatar/upload', {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: fd,
})
const data = await res.json()
if (res.ok && data.url) {
setAvatarUrl(data.url)
setAvatarFile(null)
}
} catch (err) {
console.error('Avatar upload failed:', err)
} finally {
setAvatarUploading(false)
}
}, [avatarFile])
const handleDrag = (e) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragIn = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(true)
}
const handleDragOut = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
}
const handleDrop = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const file = e.dataTransfer?.files?.[0]
if (file) handleAvatarSelect(file)
}
// ── Profile Save ───────────────────────────────────────────────────────────
const handleProfileSave = useCallback(async () => {
setSaving(true)
setProfileSaved(false)
setProfileErrors({})
try {
const fd = new FormData()
fd.append('_method', 'PUT')
fd.append('email', email)
fd.append('username', username)
fd.append('name', name)
if (homepage) fd.append('web', homepage)
if (about) fd.append('about', about)
if (signature) fd.append('signature', signature)
if (description) fd.append('description', description)
if (day) fd.append('day', day)
if (month) fd.append('month', month)
if (year) fd.append('year', year)
if (gender) fd.append('gender', gender)
if (country) fd.append('country', country)
fd.append('mailing', mailing ? '1' : '0')
fd.append('notify', notify ? '1' : '0')
fd.append('auto_post_upload', autoPost ? '1' : '0')
if (avatarFile) fd.append('avatar', avatarFile)
const res = await fetch('/profile', {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: fd,
})
if (res.ok || res.status === 302) {
setProfileSaved(true)
setAvatarFile(null)
setTimeout(() => setProfileSaved(false), 4000)
} else {
const data = await res.json().catch(() => ({}))
if (data.errors) setProfileErrors(data.errors)
else if (data.message) setProfileErrors({ _general: [data.message] })
}
} catch (err) {
console.error('Profile save failed:', err)
} finally {
setSaving(false)
}
}, [email, username, name, homepage, about, signature, description, day, month, year, gender, country, mailing, notify, autoPost, avatarFile])
// ── Password Change ────────────────────────────────────────────────────────
const handlePasswordChange = useCallback(async () => {
setPasswordSaving(true)
setPasswordSaved(false)
setPasswordErrors({})
try {
const res = await fetch('/profile/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({
current_password: currentPassword,
password: newPassword,
password_confirmation: confirmPassword,
}),
})
if (res.ok || res.status === 302) {
setPasswordSaved(true)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setTimeout(() => setPasswordSaved(false), 4000)
} else {
const data = await res.json().catch(() => ({}))
if (data.errors) setPasswordErrors(data.errors)
else if (data.message) setPasswordErrors({ _general: [data.message] })
}
} catch (err) {
console.error('Password change failed:', err)
} finally {
setPasswordSaving(false)
}
}, [currentPassword, newPassword, confirmPassword])
// ── Delete Account ─────────────────────────────────────────────────────────
const handleDeleteAccount = async () => {
setDeleting(true)
setDeleteError('')
try {
const res = await fetch('/profile', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ password: deletePassword }),
})
if (res.ok || res.status === 302) {
window.location.href = '/'
} else {
const data = await res.json().catch(() => ({}))
setDeleteError(data.errors?.password?.[0] || data.message || 'Deletion failed.')
}
} catch (err) {
setDeleteError('Request failed.')
} finally {
setDeleting(false)
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
{/* ── General Errors ── */}
{profileErrors._general && (
{profileErrors._general[0]}
)}
{/* ════════════════════════════════════════════════════════════════════
AVATAR SECTION
════════════════════════════════════════════════════════════════════ */}
Avatar
{/* Preview */}
{avatarUploading && (
)}
{/* Dropzone */}
avatarInputRef.current?.click()}
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
className={[
'w-full rounded-xl border-2 border-dashed px-5 py-5 text-left transition-all',
dragActive
? 'border-accent/50 bg-accent/10'
: 'border-white/15 hover:border-white/25 hover:bg-white/[0.03]',
].join(' ')}
>
{avatarFile ? avatarFile.name : 'Drop an image or click to browse'}
{avatarFile ? 'Ready to upload with save' : 'Recommended: 256×256 px or larger'}
handleAvatarSelect(e.target.files?.[0])}
/>
{avatarFile && (
Upload now
{ setAvatarFile(null); setAvatarUrl(initialAvatarUrl || '') }}
className="text-[11px] text-slate-500 hover:text-white transition-colors"
>
Cancel
)}
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT INFO
════════════════════════════════════════════════════════════════════ */}
Account
setUsername(e.target.value)}
error={profileErrors.username?.[0]}
hint={user?.username_changed_at ? `Last changed: ${new Date(user.username_changed_at).toLocaleDateString()}` : undefined}
/>
setEmail(e.target.value)}
error={profileErrors.email?.[0]}
required
/>
setName(e.target.value)}
placeholder="Your real name (optional)"
error={profileErrors.name?.[0]}
/>
setHomepage(e.target.value)}
placeholder="https://"
error={profileErrors.web?.[0] || profileErrors.homepage?.[0]}
/>
{/* ════════════════════════════════════════════════════════════════════
ABOUT & BIO
════════════════════════════════════════════════════════════════════ */}
{/* ════════════════════════════════════════════════════════════════════
PERSONAL DETAILS
════════════════════════════════════════════════════════════════════ */}
Personal Details
{/* Birthday */}
Birthday
setDay(e.target.value)}
options={buildDayOptions()}
size="sm"
/>
setMonth(e.target.value)}
options={MONTHS}
size="sm"
/>
setYear(e.target.value)}
options={buildYearOptions()}
size="sm"
/>
{/* Gender */}
{/* Country */}
{countryOptions.length > 0 ? (
setCountry(e.target.value)}
options={countryOptions}
placeholder="Select country"
error={profileErrors.country?.[0]}
/>
) : (
setCountry(e.target.value)}
placeholder="Country code (e.g. US, DE, TR)"
error={profileErrors.country?.[0]}
/>
)}
{/* ════════════════════════════════════════════════════════════════════
PREFERENCES
════════════════════════════════════════════════════════════════════ */}
Preferences
Mailing List
Receive occasional emails about Skinbase news
setMailing(e.target.checked)}
variant="accent"
size="md"
/>
Upload Notifications
Get notified when people you follow upload new work
setNotify(e.target.checked)}
variant="emerald"
size="md"
/>
Auto-post Uploads
Automatically post to your feed when you publish artwork
setAutoPost(e.target.checked)}
variant="sky"
size="md"
/>
{/* ── Save Profile Button ── */}
Save Profile
{profileSaved && (
Profile updated
)}
{/* ════════════════════════════════════════════════════════════════════
CHANGE PASSWORD
════════════════════════════════════════════════════════════════════ */}
Change Password
{passwordErrors._general && (
{passwordErrors._general[0]}
)}
setCurrentPassword(e.target.value)}
error={passwordErrors.current_password?.[0]}
autoComplete="current-password"
/>
setNewPassword(e.target.value)}
error={passwordErrors.password?.[0]}
hint="Minimum 8 characters"
autoComplete="new-password"
/>
setConfirmPassword(e.target.value)}
error={passwordErrors.password_confirmation?.[0]}
autoComplete="new-password"
/>
Update Password
{passwordSaved && (
Password updated
)}
{/* ════════════════════════════════════════════════════════════════════
DANGER ZONE
════════════════════════════════════════════════════════════════════ */}
Danger Zone
Delete Account
Remove your account and all associated data permanently.
setShowDelete(true)}>
Delete Account
{/* spacer for bottom padding */}
{/* ── Delete Confirmation Modal ── */}
setShowDelete(false)}
title="Delete Account"
size="sm"
footer={
setShowDelete(false)}>
Cancel
Permanently Delete
}
>
This action is irreversible . All your artworks,
comments, and profile data will be permanently deleted.
setDeletePassword(e.target.value)}
error={deleteError}
autoComplete="current-password"
/>
)
}