// The seeker's own sigil — /profile. Edits display name, bio, gender, the // procedural GhostGlyph avatar (form + hue), whether the profile is public, // and an optional recovery email. Reads GET /api/profile/me, writes // PATCH /api/profile (awaited, then a saved state — never optimistic). // // There is no image upload anywhere: the avatar is the same procedural glyph // the entities wear, so there is nothing to moderate and no EXIF to leak. import { useEffect, useMemo, useState } from 'react' import { Link, Navigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { GhostGlyph } from '../components/GhostGlyph' import { useAuth } from '../state/auth' import { genderLabel, progressPercent, rankTitle } from '../lib/hunterLabels' import * as api from '../api' import { AVATAR_FORMS, BIO_MAX, GENDERS, HUE_MAX } from '../api' import type { Gender, OwnProfile } from '../api' import type { GhostForm } from '../lib/types' import './ProfilePage.css' type LoadState = 'loading' | 'error' | 'ready' type SaveState = 'idle' | 'saving' | 'saved' | 'error' const NAME_MAX = 40 /** Same shape the backend accepts — deliberately loose, it only has to be * plausible enough to receive one recovery message. */ const EMAIL_RE = /^[^@\s]+@[^@\s.]+\.[^@\s]+$/ type Draft = { displayName: string bio: string gender: Gender form: GhostForm hue: number isPublic: boolean email: string } function draftFrom(profile: OwnProfile): Draft { return { displayName: profile.display_name ?? '', bio: profile.bio ?? '', gender: profile.gender ?? 'unspecified', form: profile.avatar_form ?? 'wisp', hue: profile.avatar_hue ?? 150, isPublic: profile.profile_public !== false, email: profile.email ?? '', } } export function ProfilePage() { const { t } = useTranslation() const { user, checking } = useAuth() const [profile, setProfile] = useState(null) const [state, setState] = useState('loading') const [draft, setDraft] = useState(null) const [saveState, setSaveState] = useState('idle') const [saveError, setSaveError] = useState(null) const isWanderer = (user?.username ?? '').startsWith('wanderer-') useEffect(() => { if (!user) return const controller = new AbortController() api .fetchOwnProfile(controller.signal) .then((data) => { setProfile(data) setDraft(draftFrom(data)) setState('ready') }) .catch(() => { if (!controller.signal.aborted) setState('error') }) return () => controller.abort() }, [user]) const bioRemaining = draft ? BIO_MAX - draft.bio.length : BIO_MAX const nameTooLong = (draft?.displayName.length ?? 0) > NAME_MAX const bioTooLong = bioRemaining < 0 const emailInvalid = !!draft?.email.trim() && !EMAIL_RE.test(draft.email.trim()) const invalid = nameTooLong || bioTooLong || emailInvalid const patch = useMemo(() => { if (!draft) return null return { display_name: draft.displayName.trim() || null, bio: draft.bio.trim() || null, gender: draft.gender, avatar_form: draft.form, avatar_hue: draft.hue, profile_public: draft.isPublic, email: draft.email.trim() || null, } }, [draft]) function edit(patchDraft: Partial) { setDraft((prev) => (prev ? { ...prev, ...patchDraft } : prev)) setSaveState('idle') setSaveError(null) } async function handleSave(event: React.FormEvent) { event.preventDefault() if (!patch || invalid) return setSaveState('saving') setSaveError(null) try { // Awaited on purpose: the saved state must mean the veil actually // took the change, not that we hoped it would. const saved = await api.saveProfile(patch) setProfile(saved) setDraft(draftFrom(saved)) setSaveState('saved') } catch (err) { setSaveState('error') setSaveError(err instanceof Error ? err.message : null) } } if (checking) return null if (!user) return return (
{t('profile.back')}

{t('profile.title')}

{t('profile.subtitle')}

{isWanderer && (

{t('profile.wanderer.body')}

{t('profile.wanderer.claim')}
)} {state === 'loading' && (

{t('profile.loading')}

)} {state === 'error' && (

{t('profile.loadError')}

)} {state === 'ready' && draft && profile && (

{t('profile.avatar.heading')}

{t('profile.avatar.note')}

{draft.displayName.trim() || profile.username}

{t('profile.rankLine', { title: rankTitle(t, profile.rank.title), level: profile.rank.level, })}

{AVATAR_FORMS.map((form) => ( ))}

{t('profile.identity.heading')}