All five agents died mid-flight (three on session limits, two on 529s), but their worktrees held real work — 17 files. Salvaged everything, wrote the missing pieces, and finished the integration by hand. PROFILES + RANK User gains display_name, bio, gender, avatar_form, avatar_hue and profile_public — all nullable, so every existing row including the guest `wanderer-` accounts stays valid with no backfill. The avatar is procedural (a GhostForm plus a hue, drawn by the same GhostGlyph that renders entities): no uploads means no moderation surface, no EXIF and no blob storage, and an `avatar_url` still slots in later without changing anything. rank.py converts encounters, essence and favor into one "standing" currency and maps it onto six one-word titles. An encounter is worth ten points to ten essence's one, because contact is what the app is about — a seeker who only buys unlocks climbs very slowly. Negative essence and favor floor at zero rather than subtracting, so a bad judgment can never demote you: rank is a record of what you have done. Level 1 costs exactly one encounter, so a new hunter sees the bar move after their first séance. Privacy invariants, verified live rather than assumed: - `email` is returned by GET /api/profile/me and by nothing else. Confirmed against the running server: zero occurrences in both public payloads. - A hidden profile 404s rather than 403s — confirming the account exists would leak exactly what hiding it was meant to prevent. WHISPERS BETWEEN HUNTERS Plain text, no attachments, no editing. Guests can RECEIVE but not send: that gives registering a felt purpose beyond keeping a codex, and closes the obvious spam vector since guest accounts are free and automatic. Verified live: alice→bob delivers, a guest send returns 403, and a third party's conversation list comes back empty — no cross-user leak. Message bodies are rendered as text nodes, never as HTML, and wrap with overflow-wrap:anywhere so a long unbroken string can't blow out the layout. THE ENCOUNTER RECORD The Codex already knew all of this — Entity.discovered_by has always been recorded and every contact was already an entity_sightings row. Nobody ever showed it. Now an entity page names its summoner and lists every hunter who has met it. Hunters who opted out of a public profile are still COUNTED but not linkable: an anonymous contact is still a contact, so a spirit's history stays honest without exposing anyone. Live on production data: Mabel Crump, discovered by Charly, 1 encounter; Charly ranks channeler (level 2) from 5 real sightings — all computed from data that was already sitting there. 385 frontend tests pass; i18n parity holds across both languages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
// 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<OwnProfile | null>(null)
|
|
const [state, setState] = useState<LoadState>('loading')
|
|
const [draft, setDraft] = useState<Draft | null>(null)
|
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
const [saveError, setSaveError] = useState<string | null>(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<api.ProfileUpdate | null>(() => {
|
|
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<Draft>) {
|
|
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 <Navigate to="/enter" replace />
|
|
|
|
return (
|
|
<div className="profile-page">
|
|
<header className="profile-header">
|
|
<Link to="/seance" className="profile-back">
|
|
{t('profile.back')}
|
|
</Link>
|
|
<h1 className="profile-title">{t('profile.title')}</h1>
|
|
<p className="profile-subtitle">{t('profile.subtitle')}</p>
|
|
</header>
|
|
|
|
{isWanderer && (
|
|
<div className="profile-wanderer" role="note">
|
|
<p className="profile-wanderer-line">{t('profile.wanderer.body')}</p>
|
|
<Link to="/enter" className="profile-wanderer-claim">
|
|
{t('profile.wanderer.claim')}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{state === 'loading' && (
|
|
<p className="profile-state" role="status">
|
|
{t('profile.loading')}
|
|
</p>
|
|
)}
|
|
{state === 'error' && (
|
|
<p className="profile-state profile-state-error" role="alert">
|
|
{t('profile.loadError')}
|
|
</p>
|
|
)}
|
|
|
|
{state === 'ready' && draft && profile && (
|
|
<form className="profile-form" onSubmit={handleSave} noValidate>
|
|
<section className="profile-panel profile-sigil">
|
|
<h2 className="profile-panel-title">{t('profile.avatar.heading')}</h2>
|
|
<p className="profile-note">{t('profile.avatar.note')}</p>
|
|
<div className="profile-preview">
|
|
<GhostGlyph hue={draft.hue} form={draft.form} size={120} />
|
|
<p className="profile-preview-name">
|
|
{draft.displayName.trim() || profile.username}
|
|
</p>
|
|
<p className="profile-preview-rank">
|
|
{t('profile.rankLine', {
|
|
title: rankTitle(t, profile.rank.title),
|
|
level: profile.rank.level,
|
|
})}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
className="profile-chips"
|
|
role="group"
|
|
aria-label={t('profile.avatar.formLabel')}
|
|
>
|
|
{AVATAR_FORMS.map((form) => (
|
|
<button
|
|
key={form}
|
|
type="button"
|
|
className={`profile-chip${draft.form === form ? ' profile-chip-active' : ''}`}
|
|
aria-pressed={draft.form === form}
|
|
onClick={() => edit({ form })}
|
|
>
|
|
{t(`codex.form.${form}`, form)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<label className="profile-field profile-hue">
|
|
<span className="profile-label">
|
|
{t('profile.avatar.hueLabel', { hue: draft.hue })}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={HUE_MAX}
|
|
value={draft.hue}
|
|
className="profile-hue-slider"
|
|
onChange={(e) => edit({ hue: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
</section>
|
|
|
|
<section className="profile-panel">
|
|
<h2 className="profile-panel-title">{t('profile.identity.heading')}</h2>
|
|
|
|
<label className="profile-field">
|
|
<span className="profile-label">{t('profile.identity.nameLabel')}</span>
|
|
<input
|
|
type="text"
|
|
className="profile-input"
|
|
value={draft.displayName}
|
|
maxLength={NAME_MAX * 2}
|
|
placeholder={profile.username}
|
|
onChange={(e) => edit({ displayName: e.target.value })}
|
|
/>
|
|
<span className="profile-hint">{t('profile.identity.nameHint')}</span>
|
|
{nameTooLong && (
|
|
<span className="profile-error" role="alert">
|
|
{t('profile.identity.nameTooLong', { max: NAME_MAX })}
|
|
</span>
|
|
)}
|
|
</label>
|
|
|
|
<label className="profile-field">
|
|
<span className="profile-label">{t('profile.identity.bioLabel')}</span>
|
|
<textarea
|
|
className="profile-textarea"
|
|
rows={4}
|
|
value={draft.bio}
|
|
onChange={(e) => edit({ bio: e.target.value })}
|
|
/>
|
|
<span
|
|
className={`profile-count${bioTooLong ? ' profile-count-over' : ''}`}
|
|
aria-live="polite"
|
|
>
|
|
{t('profile.identity.bioCount', { remaining: bioRemaining, max: BIO_MAX })}
|
|
</span>
|
|
{bioTooLong && (
|
|
<span className="profile-error" role="alert">
|
|
{t('profile.identity.bioTooLong', { max: BIO_MAX })}
|
|
</span>
|
|
)}
|
|
</label>
|
|
|
|
<div
|
|
className="profile-chips"
|
|
role="group"
|
|
aria-label={t('profile.gender.label')}
|
|
>
|
|
{GENDERS.map((gender) => (
|
|
<button
|
|
key={gender}
|
|
type="button"
|
|
className={`profile-chip${draft.gender === gender ? ' profile-chip-active' : ''}`}
|
|
aria-pressed={draft.gender === gender}
|
|
onClick={() => edit({ gender })}
|
|
>
|
|
{genderLabel(t, gender)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="profile-panel">
|
|
<h2 className="profile-panel-title">{t('profile.veil.heading')}</h2>
|
|
|
|
<label className="profile-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.isPublic}
|
|
onChange={(e) => edit({ isPublic: e.target.checked })}
|
|
/>
|
|
<span className="profile-toggle-text">
|
|
{draft.isPublic ? t('profile.veil.public') : t('profile.veil.private')}
|
|
</span>
|
|
</label>
|
|
<p className="profile-note">
|
|
{draft.isPublic ? t('profile.veil.publicNote') : t('profile.veil.privateNote')}
|
|
</p>
|
|
|
|
<label className="profile-field">
|
|
<span className="profile-label">{t('profile.email.label')}</span>
|
|
<input
|
|
type="email"
|
|
className="profile-input"
|
|
value={draft.email}
|
|
autoComplete="email"
|
|
onChange={(e) => edit({ email: e.target.value })}
|
|
/>
|
|
<span className="profile-hint">{t('profile.email.note')}</span>
|
|
{emailInvalid && (
|
|
<span className="profile-error" role="alert">
|
|
{t('profile.email.invalid')}
|
|
</span>
|
|
)}
|
|
</label>
|
|
</section>
|
|
|
|
<div className="profile-actions">
|
|
<button
|
|
type="submit"
|
|
className="profile-save"
|
|
disabled={saveState === 'saving' || invalid}
|
|
>
|
|
{saveState === 'saving' ? t('profile.saving') : t('profile.save')}
|
|
</button>
|
|
{profile.profile_public && (
|
|
<Link to={`/hunters/${profile.username}`} className="profile-view-link">
|
|
{t('profile.viewPublic')}
|
|
</Link>
|
|
)}
|
|
{saveState === 'saved' && (
|
|
<p className="profile-saved" role="status">
|
|
{t('profile.saved')}
|
|
</p>
|
|
)}
|
|
{saveState === 'error' && (
|
|
<p className="profile-error" role="alert">
|
|
{saveError ?? t('profile.saveError')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|