feat: hunter profiles, ranks, whispers, and the encounter record
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>
This commit is contained in:
329
frontend/src/pages/ProfilePage.tsx
Normal file
329
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user