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

728 lines
30 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, { 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 (
<section className={`bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
{children}
</section>
)
}
function SectionTitle({ icon, children, description }) {
return (
<div className="mb-5">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
{children}
</h3>
{description && <p className="text-xs text-slate-500 mt-1">{description}</p>}
</div>
)
}
// ─── 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 (
<SettingsLayout title="Edit Profile">
<div className="space-y-8">
{/* ── General Errors ── */}
{profileErrors._general && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{profileErrors._general[0]}
</div>
)}
{/* ════════════════════════════════════════════════════════════════════
AVATAR SECTION
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-camera" description="JPG, PNG or WebP. Max 2 MB.">
Avatar
</SectionTitle>
<div className="flex items-center gap-6">
{/* Preview */}
<div className="relative shrink-0">
<img
src={avatarUrl}
alt={username || 'Avatar'}
className="w-24 h-24 rounded-full object-cover ring-2 ring-white/10 shadow-lg"
/>
{avatarUploading && (
<div className="absolute inset-0 rounded-full bg-black/60 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
</div>
{/* Dropzone */}
<div className="flex-1 min-w-0">
<button
type="button"
onClick={() => 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(' ')}
>
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-10 h-10 rounded-lg bg-white/5 text-slate-400">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 0113.5 13H11V9.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 101.414 1.414L9 9.414V13H5.5z" />
<path d="M9 13h2v5a1 1 0 11-2 0v-5z" />
</svg>
</span>
<div className="min-w-0">
<p className="text-sm text-white/90 font-medium">
{avatarFile ? avatarFile.name : 'Drop an image or click to browse'}
</p>
<p className="text-[11px] text-slate-500 mt-0.5">
{avatarFile ? 'Ready to upload with save' : 'Recommended: 256×256 px or larger'}
</p>
</div>
</div>
</button>
<input
ref={avatarInputRef}
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
/>
{avatarFile && (
<div className="flex items-center gap-2 mt-2">
<Button variant="accent" size="xs" loading={avatarUploading} onClick={handleAvatarUpload}>
Upload now
</Button>
<button
type="button"
onClick={() => { setAvatarFile(null); setAvatarUrl(initialAvatarUrl || '') }}
className="text-[11px] text-slate-500 hover:text-white transition-colors"
>
Cancel
</button>
</div>
)}
</div>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT INFO
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-user" description="Your public identity on Skinbase.">
Account
</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<TextInput
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
error={profileErrors.username?.[0]}
hint={user?.username_changed_at ? `Last changed: ${new Date(user.username_changed_at).toLocaleDateString()}` : undefined}
/>
<TextInput
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={profileErrors.email?.[0]}
required
/>
<TextInput
label="Display Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your real name (optional)"
error={profileErrors.name?.[0]}
/>
<TextInput
label="Website"
type="url"
value={homepage}
onChange={(e) => setHomepage(e.target.value)}
placeholder="https://"
error={profileErrors.web?.[0] || profileErrors.homepage?.[0]}
/>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
ABOUT & BIO
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-pen-fancy" description="Tell the community about yourself.">
About &amp; Bio
</SectionTitle>
<div className="space-y-5">
<Textarea
label="About Me"
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="Share something about yourself…"
rows={4}
error={profileErrors.about?.[0]}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<Textarea
label="Signature"
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Forum signature"
rows={3}
error={profileErrors.signature?.[0]}
/>
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Short bio / tagline"
rows={3}
error={profileErrors.description?.[0]}
/>
</div>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
PERSONAL DETAILS
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-id-card" description="Optional details — only shown if you choose.">
Personal Details
</SectionTitle>
<div className="space-y-5">
{/* Birthday */}
<div>
<label className="text-sm font-medium text-white/85 block mb-1.5">Birthday</label>
<div className="grid grid-cols-3 gap-3 max-w-md">
<Select
placeholder="Day"
value={day}
onChange={(e) => setDay(e.target.value)}
options={buildDayOptions()}
size="sm"
/>
<Select
placeholder="Month"
value={month}
onChange={(e) => setMonth(e.target.value)}
options={MONTHS}
size="sm"
/>
<Select
placeholder="Year"
value={year}
onChange={(e) => setYear(e.target.value)}
options={buildYearOptions()}
size="sm"
/>
</div>
</div>
{/* Gender */}
<RadioGroup
label="Gender"
name="gender"
options={GENDER_OPTIONS}
value={gender}
onChange={setGender}
direction="horizontal"
error={profileErrors.gender?.[0]}
/>
{/* Country */}
{countryOptions.length > 0 ? (
<Select
label="Country"
value={country}
onChange={(e) => setCountry(e.target.value)}
options={countryOptions}
placeholder="Select country"
error={profileErrors.country?.[0]}
/>
) : (
<TextInput
label="Country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="Country code (e.g. US, DE, TR)"
error={profileErrors.country?.[0]}
/>
)}
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
PREFERENCES
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-sliders" description="Control emails and sharing behavior.">
Preferences
</SectionTitle>
<div className="space-y-4">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm text-white/90 font-medium">Mailing List</p>
<p className="text-xs text-slate-500">Receive occasional emails about Skinbase news</p>
</div>
<Toggle
checked={mailing}
onChange={(e) => setMailing(e.target.checked)}
variant="accent"
size="md"
/>
</div>
<div className="border-t border-white/5" />
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm text-white/90 font-medium">Upload Notifications</p>
<p className="text-xs text-slate-500">Get notified when people you follow upload new work</p>
</div>
<Toggle
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
variant="emerald"
size="md"
/>
</div>
<div className="border-t border-white/5" />
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm text-white/90 font-medium">Auto-post Uploads</p>
<p className="text-xs text-slate-500">Automatically post to your feed when you publish artwork</p>
</div>
<Toggle
checked={autoPost}
onChange={(e) => setAutoPost(e.target.checked)}
variant="sky"
size="md"
/>
</div>
</div>
</Section>
{/* ── Save Profile Button ── */}
<div className="flex items-center gap-3">
<Button variant="accent" size="md" loading={saving} onClick={handleProfileSave}>
Save Profile
</Button>
{profileSaved && (
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Profile updated
</span>
)}
</div>
{/* ════════════════════════════════════════════════════════════════════
CHANGE PASSWORD
════════════════════════════════════════════════════════════════════ */}
<Section className="mt-4">
<SectionTitle icon="fa-solid fa-lock" description="Use a strong, unique password.">
Change Password
</SectionTitle>
{passwordErrors._general && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300 mb-4">
{passwordErrors._general[0]}
</div>
)}
<div className="space-y-4 max-w-md">
<TextInput
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
error={passwordErrors.current_password?.[0]}
autoComplete="current-password"
/>
<TextInput
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
error={passwordErrors.password?.[0]}
hint="Minimum 8 characters"
autoComplete="new-password"
/>
<TextInput
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={passwordErrors.password_confirmation?.[0]}
autoComplete="new-password"
/>
<div className="flex items-center gap-3 pt-1">
<Button variant="secondary" size="md" loading={passwordSaving} onClick={handlePasswordChange}>
Update Password
</Button>
{passwordSaved && (
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Password updated
</span>
)}
</div>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
DANGER ZONE
════════════════════════════════════════════════════════════════════ */}
<Section className="border-red-500/20">
<SectionTitle icon="fa-solid fa-triangle-exclamation" description="Permanent actions that cannot be undone.">
Danger Zone
</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white/90 font-medium">Delete Account</p>
<p className="text-xs text-slate-500">Remove your account and all associated data permanently.</p>
</div>
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}>
Delete Account
</Button>
</div>
</Section>
{/* spacer for bottom padding */}
<div className="h-4" />
</div>
{/* ── Delete Confirmation Modal ── */}
<Modal
open={showDelete}
onClose={() => setShowDelete(false)}
title="Delete Account"
size="sm"
footer={
<div className="flex items-center gap-3 ml-auto">
<Button variant="ghost" size="sm" onClick={() => setShowDelete(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">
This action is <span className="text-red-400 font-semibold">irreversible</span>. All your artworks,
comments, and profile data will be permanently deleted.
</p>
<TextInput
label="Confirm your password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
error={deleteError}
autoComplete="current-password"
/>
</div>
</Modal>
</SettingsLayout>
)
}