feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -17,10 +16,17 @@ const SETTINGS_SECTIONS = [
|
||||
{ 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: 'content', label: 'Content', icon: 'fa-solid fa-eye-low-vision', description: 'Control mature artwork visibility.' },
|
||||
{ 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 MATURE_VISIBILITY_OPTIONS = [
|
||||
{ value: 'hide', label: 'Hide mature artworks', hint: 'Remove mature artworks from feeds and galleries whenever possible.' },
|
||||
{ value: 'blur', label: 'Blur mature artworks', hint: 'Keep them in listings, but blur thumbnails until you open them.' },
|
||||
{ value: 'show', label: 'Show mature artworks normally', hint: 'Display mature thumbnails without blur in listings.' },
|
||||
]
|
||||
|
||||
const MONTHS = [
|
||||
{ value: '1', label: 'January' },
|
||||
{ value: '2', label: 'February' },
|
||||
@@ -131,10 +137,10 @@ function ErrorMessage({ text, className = '' }) {
|
||||
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">
|
||||
<header className="flex flex-col gap-4 border-b border-white/[0.06] px-1.5 pb-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center 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">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center self-start rounded-xl bg-accent/10 text-accent md:self-center">
|
||||
<i className={`${icon} text-sm`} />
|
||||
</span>
|
||||
) : null}
|
||||
@@ -143,9 +149,9 @@ function SectionCard({ title, description, icon, children, actionSlot }) {
|
||||
{description ? <p className="mt-1 text-sm text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{actionSlot ? <div>{actionSlot}</div> : null}
|
||||
{actionSlot ? <div className="shrink-0 self-start md:self-center">{actionSlot}</div> : null}
|
||||
</header>
|
||||
<div className="pt-5">{children}</div>
|
||||
<div className="px-1.5 pt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -221,6 +227,10 @@ export default function ProfileEdit() {
|
||||
comment_notifications: !!user?.comment_notifications,
|
||||
newsletter: !!user?.newsletter,
|
||||
})
|
||||
const [contentForm, setContentForm] = useState({
|
||||
mature_content_visibility: user?.mature_content_visibility || 'blur',
|
||||
mature_content_warning_enabled: user?.mature_content_warning_enabled !== false,
|
||||
})
|
||||
const [securityForm, setSecurityForm] = useState({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
@@ -234,6 +244,7 @@ export default function ProfileEdit() {
|
||||
account: {},
|
||||
personal: {},
|
||||
notifications: {},
|
||||
content: {},
|
||||
security: {},
|
||||
})
|
||||
const [captchaState, setCaptchaState] = useState({
|
||||
@@ -265,6 +276,7 @@ export default function ProfileEdit() {
|
||||
accountForm,
|
||||
personalForm,
|
||||
notificationForm,
|
||||
contentForm,
|
||||
avatarUrl: initialAvatarUrl || '',
|
||||
})
|
||||
|
||||
@@ -279,13 +291,14 @@ export default function ProfileEdit() {
|
||||
account: !equalsObject(accountForm, initialRef.current.accountForm),
|
||||
personal: !equalsObject(personalForm, initialRef.current.personalForm),
|
||||
notifications: !equalsObject(notificationForm, initialRef.current.notificationForm),
|
||||
content: !equalsObject(contentForm, initialRef.current.contentForm),
|
||||
security:
|
||||
!!securityForm.current_password ||
|
||||
!!securityForm.new_password ||
|
||||
!!securityForm.new_password_confirmation,
|
||||
danger: false,
|
||||
}
|
||||
}, [profileForm, accountForm, personalForm, notificationForm, securityForm, avatarFile, avatarPosition, removeAvatar, avatarUrl])
|
||||
}, [profileForm, accountForm, personalForm, notificationForm, contentForm, securityForm, avatarFile, avatarPosition, removeAvatar, avatarUrl])
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() => Object.entries(dirtyMap).some(([key, dirty]) => key !== 'danger' && dirty),
|
||||
@@ -777,6 +790,42 @@ export default function ProfileEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveContentSection = async (event) => {
|
||||
event.preventDefault()
|
||||
setSavingSection('content')
|
||||
clearSectionStatus('content')
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/content/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({ ...contentForm, homepage_url: '' })),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('content', payload)) {
|
||||
return
|
||||
}
|
||||
updateSectionErrors('content', payload.errors || { _general: [payload.message || 'Unable to save content settings.'] })
|
||||
return
|
||||
}
|
||||
|
||||
initialRef.current.contentForm = { ...contentForm }
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'content', text: payload.message || 'Content settings saved successfully.' })
|
||||
} catch (error) {
|
||||
updateSectionErrors('content', { _general: ['Request failed. Please try again.'] })
|
||||
} finally {
|
||||
setSavingSection('')
|
||||
}
|
||||
}
|
||||
|
||||
const saveSecuritySection = async (event) => {
|
||||
event.preventDefault()
|
||||
setSavingSection('security')
|
||||
@@ -967,14 +1016,16 @@ export default function ProfileEdit() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<Select
|
||||
<NovaSelect
|
||||
label="Avatar crop position"
|
||||
placeholder="Select crop position"
|
||||
value={avatarPosition}
|
||||
onChange={(e) => {
|
||||
setAvatarPosition(e.target.value)
|
||||
onChange={(nextValue) => {
|
||||
setAvatarPosition(nextValue)
|
||||
clearSectionStatus('profile')
|
||||
}}
|
||||
options={AVATAR_POSITION_OPTIONS}
|
||||
searchable={false}
|
||||
hint="Applies when saving a newly selected avatar"
|
||||
/>
|
||||
</div>
|
||||
@@ -1142,32 +1193,35 @@ export default function ProfileEdit() {
|
||||
<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
|
||||
<NovaSelect
|
||||
placeholder="Day"
|
||||
value={personalForm.day}
|
||||
onChange={(e) => {
|
||||
setPersonalForm((prev) => ({ ...prev, day: e.target.value }))
|
||||
onChange={(nextValue) => {
|
||||
setPersonalForm((prev) => ({ ...prev, day: nextValue ?? '' }))
|
||||
clearSectionStatus('personal')
|
||||
}}
|
||||
options={dayOptions}
|
||||
searchable={false}
|
||||
/>
|
||||
<Select
|
||||
<NovaSelect
|
||||
placeholder="Month"
|
||||
value={personalForm.month}
|
||||
onChange={(e) => {
|
||||
setPersonalForm((prev) => ({ ...prev, month: e.target.value }))
|
||||
onChange={(nextValue) => {
|
||||
setPersonalForm((prev) => ({ ...prev, month: nextValue ?? '' }))
|
||||
clearSectionStatus('personal')
|
||||
}}
|
||||
options={MONTHS}
|
||||
searchable={false}
|
||||
/>
|
||||
<Select
|
||||
<NovaSelect
|
||||
placeholder="Year"
|
||||
value={personalForm.year}
|
||||
onChange={(e) => {
|
||||
setPersonalForm((prev) => ({ ...prev, year: e.target.value }))
|
||||
onChange={(nextValue) => {
|
||||
setPersonalForm((prev) => ({ ...prev, year: nextValue ?? '' }))
|
||||
clearSectionStatus('personal')
|
||||
}}
|
||||
options={yearOptions}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
{errorsBySection.personal.birthday?.[0] ? (
|
||||
@@ -1280,6 +1334,90 @@ export default function ProfileEdit() {
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{activeSection === 'content' ? (
|
||||
<form className="space-y-4" onSubmit={saveContentSection}>
|
||||
<SectionCard
|
||||
title="Content Preferences"
|
||||
icon="fa-solid fa-eye-low-vision"
|
||||
description="Decide how mature artworks should appear in listings and artwork detail pages."
|
||||
actionSlot={
|
||||
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'content'}>
|
||||
Save Content Settings
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ErrorMessage text={errorsBySection.content._general?.[0]} className="mb-4" />
|
||||
<SuccessMessage text={sectionSaved} className="mb-4" />
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">Mature artwork visibility</label>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{MATURE_VISIBILITY_OPTIONS.map((option) => {
|
||||
const isActive = contentForm.mature_content_visibility === option.value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setContentForm((prev) => ({ ...prev, mature_content_visibility: option.value }))
|
||||
clearSectionStatus('content')
|
||||
}}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-amber-300/50 bg-amber-400/10 text-white shadow-[0_0_0_1px_rgba(251,191,36,0.15)]'
|
||||
: 'border-white/[0.08] bg-white/[0.02] text-slate-300 hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${isActive ? 'bg-amber-300/15 text-amber-200' : 'bg-white/[0.06] text-slate-400'}`}>
|
||||
<i className={`fa-solid ${option.value === 'hide' ? 'fa-eye-slash' : option.value === 'blur' ? 'fa-droplet-slash' : 'fa-eye'}`} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{option.label}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{option.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{errorsBySection.content.mature_content_visibility?.[0] ? (
|
||||
<p className="mt-2 text-xs text-red-300">{errorsBySection.content.mature_content_visibility[0]}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div 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="fa-solid fa-triangle-exclamation text-xs" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white/90">Show warning before opening mature artwork pages</p>
|
||||
<p className="text-xs text-slate-500">Display an interstitial on artwork detail pages before revealing mature media.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!contentForm.mature_content_warning_enabled}
|
||||
onChange={(e) => {
|
||||
setContentForm((prev) => ({ ...prev, mature_content_warning_enabled: e.target.checked }))
|
||||
clearSectionStatus('content')
|
||||
}}
|
||||
variant="accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorsBySection.content.mature_content_warning_enabled?.[0] ? (
|
||||
<p className="text-xs text-red-300">{errorsBySection.content.mature_content_warning_enabled[0]}</p>
|
||||
) : null}
|
||||
|
||||
{renderCaptchaChallenge('content')}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{activeSection === 'security' ? (
|
||||
<form className="space-y-4" onSubmit={saveSecuritySection}>
|
||||
<SectionCard
|
||||
|
||||
Reference in New Issue
Block a user