feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

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