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:
@@ -11,7 +11,11 @@ import { DevicesPage } from './pages/DevicesPage'
|
||||
import { EnterPage } from './pages/EnterPage'
|
||||
import { GhostLogPage } from './pages/GhostLogPage'
|
||||
import { InventoryPage } from './pages/InventoryPage'
|
||||
import { HunterPage } from './pages/HunterPage'
|
||||
import { HuntersPage } from './pages/HuntersPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { MessagesPage } from './pages/MessagesPage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { SeancePage } from './pages/SeancePage'
|
||||
import { ShopPage } from './pages/ShopPage'
|
||||
|
||||
@@ -35,6 +39,11 @@ function App() {
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="/inventory" element={<InventoryPage />} />
|
||||
<Route path="/devices" element={<DevicesPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/hunters" element={<HuntersPage />} />
|
||||
<Route path="/hunters/:username" element={<HunterPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/messages/:username" element={<MessagesPage />} />
|
||||
<Route path="*" element={<LandingPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GhostForm, Rarity } from './lib/types'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
@@ -51,3 +53,119 @@ export function logout(): Promise<void> {
|
||||
export function me(): Promise<User> {
|
||||
return request<User>('/auth/me')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hunter profiles — the single place the app talks to Workstream P's
|
||||
// /api/profile and /api/hunters endpoints, so a backend shape change is a
|
||||
// one-file fix rather than a hunt through three pages.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GENDERS = ['male', 'female', 'unspecified'] as const
|
||||
export type Gender = (typeof GENDERS)[number]
|
||||
|
||||
export const AVATAR_FORMS: readonly GhostForm[] = ['wisp', 'banshee', 'fairy', 'shade']
|
||||
|
||||
/** Bio cap enforced client-side; the backend enforces the same 280 limit. */
|
||||
export const BIO_MAX = 280
|
||||
export const HUE_MAX = 359
|
||||
|
||||
/** Rank block carried inside every profile payload. `progress` is a 0-1
|
||||
* fraction toward `next_at`; `next_at` is null at the top of the curve. */
|
||||
export type Rank = {
|
||||
level: number
|
||||
title: string
|
||||
encounters: number
|
||||
next_at: number | null
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** GET /api/profile/me — the seeker's own profile, email included. */
|
||||
export type OwnProfile = {
|
||||
username: string
|
||||
display_name: string | null
|
||||
bio: string | null
|
||||
gender: Gender | null
|
||||
avatar_form: GhostForm | null
|
||||
avatar_hue: number | null
|
||||
profile_public: boolean
|
||||
email: string | null
|
||||
rank: Rank
|
||||
}
|
||||
|
||||
/** Body of PATCH /api/profile — every field optional, only sent fields change. */
|
||||
export type ProfileUpdate = {
|
||||
display_name?: string | null
|
||||
bio?: string | null
|
||||
gender?: Gender
|
||||
avatar_form?: GhostForm
|
||||
avatar_hue?: number
|
||||
profile_public?: boolean
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export type HunterEntity = {
|
||||
id: string
|
||||
name: string
|
||||
epithet: string
|
||||
rarity: Rarity | string
|
||||
visual: { hue?: number; form?: string }
|
||||
}
|
||||
|
||||
/** GET /api/hunters/{username} — public profile. Never carries an email. */
|
||||
export type PublicHunter = {
|
||||
username: string
|
||||
display_name: string | null
|
||||
bio: string | null
|
||||
gender: Gender | null
|
||||
avatar_form: GhostForm | null
|
||||
avatar_hue: number | null
|
||||
joined_at: string | null
|
||||
rank: Rank
|
||||
recent_entities: HunterEntity[]
|
||||
}
|
||||
|
||||
/** GET /api/hunters — one roster row. */
|
||||
export type RosterHunter = {
|
||||
username: string
|
||||
display_name: string | null
|
||||
avatar_form: GhostForm | null
|
||||
avatar_hue: number | null
|
||||
rank: Rank
|
||||
}
|
||||
|
||||
export function fetchOwnProfile(signal?: AbortSignal): Promise<OwnProfile> {
|
||||
return request<OwnProfile>('/api/profile/me', { signal })
|
||||
}
|
||||
|
||||
export function saveProfile(patch: ProfileUpdate): Promise<OwnProfile> {
|
||||
return request<OwnProfile>('/api/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves to null when the hunter is absent *or* has veiled their profile —
|
||||
* the backend 404s for both on purpose, and the page says so in-fiction
|
||||
* instead of surfacing a status code. */
|
||||
export async function fetchHunter(
|
||||
username: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PublicHunter | null> {
|
||||
const res = await fetch(`/api/hunters/${encodeURIComponent(username)}`, {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
})
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) throw new Error(`the roll answered ${res.status}`)
|
||||
return (await res.json()) as PublicHunter
|
||||
}
|
||||
|
||||
export async function fetchHunters(signal?: AbortSignal): Promise<RosterHunter[]> {
|
||||
const data = await request<{ hunters: RosterHunter[] }>('/api/hunters', { signal })
|
||||
return data.hunters ?? []
|
||||
}
|
||||
|
||||
/** Public name for a hunter, falling back to their username. */
|
||||
export function hunterName(h: { display_name: string | null; username: string }): string {
|
||||
return h.display_name?.trim() || h.username
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"seance": "séance",
|
||||
"enter": "enter",
|
||||
"logout": "depart",
|
||||
"log": "log"
|
||||
"log": "log",
|
||||
"hunters": "hunters"
|
||||
},
|
||||
"log": {
|
||||
"title": "GHOST LOG",
|
||||
@@ -620,5 +621,115 @@
|
||||
"idle": "the veil is quiet — no presence detected",
|
||||
"waiting": "listening for tells…",
|
||||
"meterLabel": "evil meter"
|
||||
},
|
||||
"profile": {
|
||||
"back": "← the table",
|
||||
"title": "YOUR SIGIL",
|
||||
"subtitle": "how the veil will remember you",
|
||||
"loading": "reading your sigil…",
|
||||
"loadError": "your sigil will not resolve — the glass is clouded",
|
||||
"save": "seal it",
|
||||
"saving": "sealing…",
|
||||
"saved": "sealed — the veil has taken it",
|
||||
"saveError": "the veil refused the change",
|
||||
"viewPublic": "see what others see",
|
||||
"rankLine": "{{title}} · rank {{level}}",
|
||||
"titles": {
|
||||
"curious": "curious",
|
||||
"sensitive": "sensitive",
|
||||
"channeler": "channeler",
|
||||
"medium": "medium",
|
||||
"adept": "adept",
|
||||
"oracle": "oracle"
|
||||
},
|
||||
"wanderer": {
|
||||
"body": "you drift here without a name. wanderers may look, but the veil forgets them at dawn — claim a name and your sigil will hold.",
|
||||
"claim": "claim a name"
|
||||
},
|
||||
"avatar": {
|
||||
"heading": "your shape",
|
||||
"note": "no likeness is taken. your sigil is drawn from a form and a colour, the same way the dead are drawn.",
|
||||
"formLabel": "choose a form",
|
||||
"hueLabel": "colour · {{hue}}°"
|
||||
},
|
||||
"identity": {
|
||||
"heading": "your name",
|
||||
"nameLabel": "name others will read",
|
||||
"nameHint": "leave it blank and the veil will use your true name",
|
||||
"nameTooLong": "no more than {{max}} characters",
|
||||
"bioLabel": "a few words on yourself",
|
||||
"bioCount": "{{remaining}} of {{max}} left",
|
||||
"bioTooLong": "no more than {{max}} characters"
|
||||
},
|
||||
"gender": {
|
||||
"label": "how you are addressed",
|
||||
"male": "he",
|
||||
"female": "she",
|
||||
"unspecified": "unspoken"
|
||||
},
|
||||
"veil": {
|
||||
"heading": "the veil over you",
|
||||
"public": "visible to other seekers",
|
||||
"private": "veiled",
|
||||
"publicNote": "your sigil, rank and recent contacts can be read by anyone who knows your name.",
|
||||
"privateNote": "no one will find you on the roll. your séances continue unchanged."
|
||||
},
|
||||
"email": {
|
||||
"label": "a way back (optional)",
|
||||
"note": "for recovering your account only. never shown on your page, never given to another seeker, never used to reach you otherwise.",
|
||||
"invalid": "that address will not carry a message"
|
||||
}
|
||||
},
|
||||
"hunters": {
|
||||
"back": "← the table",
|
||||
"backToRoster": "← the roll",
|
||||
"title": "THE ROLL",
|
||||
"subtitle": "every seeker who left their name on the veil",
|
||||
"loading": "calling the roll…",
|
||||
"empty": "no one has left a name yet",
|
||||
"card": {
|
||||
"encounters_one": "{{count}} spirit reached",
|
||||
"encounters_other": "{{count}} spirits reached"
|
||||
},
|
||||
"profile": {
|
||||
"loading": "tracing their sigil…",
|
||||
"veiledTitle": "no trace of them",
|
||||
"veiledBody": "the veil holds no record of “{{username}}”. either the name was never claimed, or they have drawn the veil over themselves.",
|
||||
"progressLabel": "progress toward the next rank",
|
||||
"progressNext_one": "{{count}} more spirit to rise",
|
||||
"progressNext_other": "{{count}} more spirits to rise",
|
||||
"progressMax": "as far as the veil goes",
|
||||
"encounters": "spirits reached",
|
||||
"joined": "first crossed",
|
||||
"recentTitle": "recently reached",
|
||||
"recentEmpty": "they have reached no one yet",
|
||||
"sendMessage": "send word"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "whispers between hunters",
|
||||
"findHunters": "find hunters",
|
||||
"emptyInbox": "no one has whispered to you yet. find a hunter whose codex you admire, and say something.",
|
||||
"emptyThread": "nothing has passed between you yet.",
|
||||
"needAccount": "whispers need a name. a wanderer's voice does not carry.",
|
||||
"claimName": "claim a name",
|
||||
"backToInbox": "back to the whispers",
|
||||
"noSuchHunter": "no such hunter walks here.",
|
||||
"placeholder": "speak, and it will carry…",
|
||||
"send": "send",
|
||||
"sending": "sending…",
|
||||
"sendFailed": "the whisper did not carry. try again.",
|
||||
"guestBlocked": "a wanderer cannot be heard. claim a name and your whispers will carry.",
|
||||
"youPrefix": "you: ",
|
||||
"unread": "unread whispers",
|
||||
"time": {
|
||||
"now": "just now",
|
||||
"minutes_one": "{{count}} minute past",
|
||||
"minutes_other": "{{count}} minutes past",
|
||||
"hours_one": "{{count}} hour past",
|
||||
"hours_other": "{{count}} hours past",
|
||||
"days_one": "{{count}} day past",
|
||||
"days_other": "{{count}} days past"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"seance": "sesión",
|
||||
"enter": "entrar",
|
||||
"logout": "partir",
|
||||
"log": "registro"
|
||||
"log": "registro",
|
||||
"hunters": "lista"
|
||||
},
|
||||
"log": {
|
||||
"title": "REGISTRO ESPECTRAL",
|
||||
@@ -620,5 +621,115 @@
|
||||
"idle": "el velo está en silencio — no se detecta presencia",
|
||||
"waiting": "escuchando indicios…",
|
||||
"meterLabel": "medidor de maldad"
|
||||
},
|
||||
"profile": {
|
||||
"back": "← la mesa",
|
||||
"title": "TU SIGILO",
|
||||
"subtitle": "cómo el velo te recordará",
|
||||
"loading": "leyendo tu sigilo…",
|
||||
"loadError": "tu sigilo no se aclara — el cristal está nublado",
|
||||
"save": "séllalo",
|
||||
"saving": "sellando…",
|
||||
"saved": "sellado — el velo lo ha tomado",
|
||||
"saveError": "el velo rechazó el cambio",
|
||||
"viewPublic": "mira lo que otros ven",
|
||||
"rankLine": "{{title}} · rango {{level}}",
|
||||
"titles": {
|
||||
"curious": "curioso",
|
||||
"sensitive": "sensitivo",
|
||||
"channeler": "canalizador",
|
||||
"medium": "médium",
|
||||
"adept": "adepto",
|
||||
"oracle": "oráculo"
|
||||
},
|
||||
"wanderer": {
|
||||
"body": "vagas aquí sin nombre. a los errantes se les permite mirar, pero el velo los olvida al amanecer — reclama un nombre y tu sigilo perdurará.",
|
||||
"claim": "reclamar un nombre"
|
||||
},
|
||||
"avatar": {
|
||||
"heading": "tu forma",
|
||||
"note": "no se toma ninguna imagen. tu sigilo se traza con una forma y un color, igual que se trazan los muertos.",
|
||||
"formLabel": "elige una forma",
|
||||
"hueLabel": "color · {{hue}}°"
|
||||
},
|
||||
"identity": {
|
||||
"heading": "tu nombre",
|
||||
"nameLabel": "nombre que otros leerán",
|
||||
"nameHint": "déjalo vacío y el velo usará tu nombre verdadero",
|
||||
"nameTooLong": "no más de {{max}} caracteres",
|
||||
"bioLabel": "unas palabras sobre ti",
|
||||
"bioCount": "quedan {{remaining}} de {{max}}",
|
||||
"bioTooLong": "no más de {{max}} caracteres"
|
||||
},
|
||||
"gender": {
|
||||
"label": "cómo te nombran",
|
||||
"male": "él",
|
||||
"female": "ella",
|
||||
"unspecified": "sin decir"
|
||||
},
|
||||
"veil": {
|
||||
"heading": "el velo sobre ti",
|
||||
"public": "visible a otros buscadores",
|
||||
"private": "velado",
|
||||
"publicNote": "tu sigilo, tu rango y tus contactos recientes podrán leerse por quien sepa tu nombre.",
|
||||
"privateNote": "nadie te hallará en la lista. tus sesiones siguen igual."
|
||||
},
|
||||
"email": {
|
||||
"label": "un camino de vuelta (opcional)",
|
||||
"note": "sólo para recuperar tu cuenta. nunca se muestra en tu página, nunca se da a otro buscador, nunca se usa para nada más.",
|
||||
"invalid": "esa dirección no llevará ningún mensaje"
|
||||
}
|
||||
},
|
||||
"hunters": {
|
||||
"back": "← la mesa",
|
||||
"backToRoster": "← la lista",
|
||||
"title": "LA LISTA",
|
||||
"subtitle": "todo buscador que dejó su nombre en el velo",
|
||||
"loading": "pasando lista…",
|
||||
"empty": "nadie ha dejado su nombre aún",
|
||||
"card": {
|
||||
"encounters_one": "{{count}} espíritu alcanzado",
|
||||
"encounters_other": "{{count}} espíritus alcanzados"
|
||||
},
|
||||
"profile": {
|
||||
"loading": "trazando su sigilo…",
|
||||
"veiledTitle": "ningún rastro de ellos",
|
||||
"veiledBody": "el velo no guarda registro de «{{username}}». o el nombre nunca fue reclamado, o han corrido el velo sobre sí mismos.",
|
||||
"progressLabel": "avance hacia el siguiente rango",
|
||||
"progressNext_one": "{{count}} espíritu más para ascender",
|
||||
"progressNext_other": "{{count}} espíritus más para ascender",
|
||||
"progressMax": "hasta donde llega el velo",
|
||||
"encounters": "espíritus alcanzados",
|
||||
"joined": "primer cruce",
|
||||
"recentTitle": "alcanzados hace poco",
|
||||
"recentEmpty": "aún no han alcanzado a nadie",
|
||||
"sendMessage": "enviar palabra"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "susurros entre cazadores",
|
||||
"findHunters": "buscar cazadores",
|
||||
"emptyInbox": "nadie te ha susurrado aún. busca a un cazador cuyo códice admires, y di algo.",
|
||||
"emptyThread": "nada ha pasado entre ustedes todavía.",
|
||||
"needAccount": "los susurros necesitan un nombre. la voz de un errante no llega lejos.",
|
||||
"claimName": "reclama un nombre",
|
||||
"backToInbox": "volver a los susurros",
|
||||
"noSuchHunter": "ningún cazador camina aquí.",
|
||||
"placeholder": "habla, y llegará…",
|
||||
"send": "enviar",
|
||||
"sending": "enviando…",
|
||||
"sendFailed": "el susurro no llegó. inténtalo de nuevo.",
|
||||
"guestBlocked": "un errante no puede ser oído. reclama un nombre y tus susurros llegarán.",
|
||||
"youPrefix": "tú: ",
|
||||
"unread": "susurros sin leer",
|
||||
"time": {
|
||||
"now": "ahora mismo",
|
||||
"minutes_one": "hace {{count}} minuto",
|
||||
"minutes_other": "hace {{count}} minutos",
|
||||
"hours_one": "hace {{count}} hora",
|
||||
"hours_other": "hace {{count}} horas",
|
||||
"days_one": "hace {{count}} día",
|
||||
"days_other": "hace {{count}} días"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/src/lib/hunterLabels.ts
Normal file
56
frontend/src/lib/hunterLabels.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Label helpers shared by the three hunter pages.
|
||||
//
|
||||
// These deliberately use *literal* t() keys inside a switch rather than a
|
||||
// computed `t(\`profile.titles.${x}\`)` template: the i18n coverage checker
|
||||
// only tracks literal keys without a hand-written domain rule, and rank
|
||||
// titles are decided by the backend, so a literal switch keeps the gate
|
||||
// honest and falls back to the raw word if P ever adds a band.
|
||||
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { Gender } from '../api'
|
||||
|
||||
/** Rank bands from backend rank.py, lowest to highest. */
|
||||
export const RANK_TITLES = [
|
||||
'curious',
|
||||
'sensitive',
|
||||
'channeler',
|
||||
'medium',
|
||||
'adept',
|
||||
'oracle',
|
||||
] as const
|
||||
|
||||
export function rankTitle(t: TFunction, title: string): string {
|
||||
switch (title) {
|
||||
case 'curious':
|
||||
return t('profile.titles.curious')
|
||||
case 'sensitive':
|
||||
return t('profile.titles.sensitive')
|
||||
case 'channeler':
|
||||
return t('profile.titles.channeler')
|
||||
case 'medium':
|
||||
return t('profile.titles.medium')
|
||||
case 'adept':
|
||||
return t('profile.titles.adept')
|
||||
case 'oracle':
|
||||
return t('profile.titles.oracle')
|
||||
default:
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
export function genderLabel(t: TFunction, gender: Gender): string {
|
||||
switch (gender) {
|
||||
case 'male':
|
||||
return t('profile.gender.male')
|
||||
case 'female':
|
||||
return t('profile.gender.female')
|
||||
default:
|
||||
return t('profile.gender.unspecified')
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamps a rank progress fraction into 0-100 for a bar width. */
|
||||
export function progressPercent(progress: number): number {
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.round(Math.min(1, Math.max(0, progress)) * 100)
|
||||
}
|
||||
387
frontend/src/pages/HunterPage.css
Normal file
387
frontend/src/pages/HunterPage.css
Normal file
@@ -0,0 +1,387 @@
|
||||
/* A hunter's public page. Same tokens as the Codex, and the entity cards
|
||||
deliberately reuse the Codex card look so the grid reads as one registry. */
|
||||
|
||||
.hunter-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 1.6rem clamp(0.9rem, 4vw, 3rem) 5rem;
|
||||
}
|
||||
|
||||
.hunter-topbar {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 1.2rem;
|
||||
}
|
||||
|
||||
.hunter-back,
|
||||
.hunter-missing-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.hunter-back:hover,
|
||||
.hunter-back:focus-visible,
|
||||
.hunter-missing-link:hover,
|
||||
.hunter-missing-link:focus-visible {
|
||||
color: #7cffb2;
|
||||
}
|
||||
|
||||
/* ---- states ---- */
|
||||
|
||||
.hunter-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 3rem 0;
|
||||
animation: hunter-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hunter-state-error {
|
||||
color: #ff3b5c;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hunter-missing {
|
||||
max-width: 560px;
|
||||
margin: 3rem auto;
|
||||
text-align: center;
|
||||
padding: 2rem 1.4rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(178, 107, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hunter-missing-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.3rem, 5vw, 1.9rem);
|
||||
letter-spacing: 0.16em;
|
||||
margin: 0 0 0.8rem;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.hunter-missing-body {
|
||||
margin: 0 0 1.2rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.6);
|
||||
}
|
||||
|
||||
/* ---- hero ---- */
|
||||
|
||||
.hunter-hero {
|
||||
max-width: 620px;
|
||||
margin: 0 auto 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: 2rem clamp(0.9rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.hunter-glyph .ghost-glyph {
|
||||
animation: hunter-drift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hunter-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.6rem, 6vw, 2.4rem);
|
||||
letter-spacing: 0.14em;
|
||||
margin: 0.5rem 0 0;
|
||||
color: #eef7f0;
|
||||
text-shadow: 0 0 18px rgba(124, 255, 178, 0.28);
|
||||
}
|
||||
|
||||
.hunter-handle {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.16em;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
.hunter-rank {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(178, 107, 255, 0.9);
|
||||
}
|
||||
|
||||
.hunter-progress {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(124, 255, 178, 0.12);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hunter-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #7cffb2, #b26bff);
|
||||
box-shadow: 0 0 12px rgba(124, 255, 178, 0.45);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.hunter-progress-note {
|
||||
margin: 0;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(216, 230, 220, 0.45);
|
||||
}
|
||||
|
||||
.hunter-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.4rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.hunter-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.hunter-stat dt {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
.hunter-stat dd {
|
||||
margin: 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.1rem;
|
||||
color: #7cffb2;
|
||||
}
|
||||
|
||||
.hunter-bio {
|
||||
margin: 1.2rem 0 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
color: rgba(216, 230, 220, 0.8);
|
||||
border-left: 2px solid rgba(178, 107, 255, 0.4);
|
||||
padding-left: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hunter-message-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: 1.4rem;
|
||||
padding: 0 1.6rem;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.hunter-message-link:hover,
|
||||
.hunter-message-link:focus-visible {
|
||||
box-shadow: 0 0 20px rgba(124, 255, 178, 0.5);
|
||||
}
|
||||
|
||||
/* ---- recent entities (Codex card styling) ---- */
|
||||
|
||||
.hunter-recent {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hunter-section-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
color: rgba(124, 255, 178, 0.8);
|
||||
margin: 0 0 1.4rem;
|
||||
}
|
||||
|
||||
.hunter-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.hunter-card-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hunter-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem 0.9rem;
|
||||
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.hunter-card:hover,
|
||||
.hunter-card:focus-visible {
|
||||
outline: none;
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(124, 255, 178, 0.5);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(124, 255, 178, 0.14);
|
||||
}
|
||||
|
||||
.hunter-card[data-rarity='rare']:hover,
|
||||
.hunter-card[data-rarity='rare']:focus-visible {
|
||||
border-color: rgba(178, 107, 255, 0.55);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(178, 107, 255, 0.2);
|
||||
}
|
||||
|
||||
.hunter-card[data-rarity='mythic']:hover,
|
||||
.hunter-card[data-rarity='mythic']:focus-visible {
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 28px rgba(255, 157, 74, 0.22);
|
||||
}
|
||||
|
||||
.hunter-card-glyph-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.hunter-card-glyph {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
hsl(var(--glyph-hue), 82%, 74%) 0%,
|
||||
hsl(var(--glyph-hue), 80%, 65%) 24%,
|
||||
hsla(var(--glyph-hue), 80%, 55%, 0.32) 50%,
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(1.5px);
|
||||
opacity: 0.9;
|
||||
animation: hunter-drift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hunter-card-form {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
.hunter-card-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.hunter-card-epithet {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
font-style: italic;
|
||||
color: rgba(178, 107, 255, 0.75);
|
||||
}
|
||||
|
||||
.hunter-rarity {
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.26em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hunter-rarity[data-rarity='common'] {
|
||||
color: #8a8f98;
|
||||
border-color: rgba(138, 143, 152, 0.45);
|
||||
}
|
||||
|
||||
.hunter-rarity[data-rarity='uncommon'] {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.hunter-rarity[data-rarity='rare'] {
|
||||
color: #b26bff;
|
||||
border-color: rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.hunter-rarity[data-rarity='mythic'] {
|
||||
color: #ff9d4a;
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
}
|
||||
|
||||
/* ---- motion ---- */
|
||||
|
||||
@keyframes hunter-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hunter-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hunter-state,
|
||||
.hunter-glyph .ghost-glyph,
|
||||
.hunter-card-glyph {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hunter-card,
|
||||
.hunter-progress-fill,
|
||||
.hunter-message-link,
|
||||
.hunter-back {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
210
frontend/src/pages/HunterPage.tsx
Normal file
210
frontend/src/pages/HunterPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
// A hunter's public page — /hunters/:username. Reads GET /api/hunters/{u},
|
||||
// which 404s both for names that were never claimed and for seekers who have
|
||||
// veiled themselves; both arrive here as one in-fiction "no trace" state.
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GhostGlyph } from '../components/GhostGlyph'
|
||||
import { usePageMeta } from '../lib/pageMeta'
|
||||
import { genderLabel, progressPercent, rankTitle } from '../lib/hunterLabels'
|
||||
import * as api from '../api'
|
||||
import { hunterName } from '../api'
|
||||
import type { PublicHunter } from '../api'
|
||||
import type { GhostForm } from '../lib/types'
|
||||
import './HunterPage.css'
|
||||
|
||||
type LoadState = 'loading' | 'error' | 'missing' | 'ready'
|
||||
|
||||
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
|
||||
|
||||
function asForm(form: string | null | undefined): GhostForm {
|
||||
return GHOST_FORMS.includes(form ?? '') ? (form as GhostForm) : 'wisp'
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null, locale: string): string | null {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function HunterPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { username = '' } = useParams()
|
||||
const [hunter, setHunter] = useState<PublicHunter | null>(null)
|
||||
const [state, setState] = useState<LoadState>('loading')
|
||||
|
||||
usePageMeta({
|
||||
title: `${username} — a hunter of the veil`,
|
||||
description: `The public record of ${username}: rank, encounters, and the spirits they have reached through Quantumancy.`,
|
||||
path: `/hunters/${username}`,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) {
|
||||
setState('missing')
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
setState('loading')
|
||||
api
|
||||
.fetchHunter(username, controller.signal)
|
||||
.then((data) => {
|
||||
if (!data) {
|
||||
setState('missing')
|
||||
return
|
||||
}
|
||||
setHunter(data)
|
||||
setState('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState('error')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [username])
|
||||
|
||||
const joined = formatDate(hunter?.joined_at ?? null, i18n.language)
|
||||
|
||||
return (
|
||||
<div className="hunter-page">
|
||||
<header className="hunter-topbar">
|
||||
<Link to="/hunters" className="hunter-back">
|
||||
{t('hunters.backToRoster')}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{state === 'loading' && (
|
||||
<p className="hunter-state" role="status">
|
||||
{t('hunters.profile.loading')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<p className="hunter-state hunter-state-error" role="alert">
|
||||
{t('common.error')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'missing' && (
|
||||
<div className="hunter-missing" role="status">
|
||||
<h1 className="hunter-missing-title">{t('hunters.profile.veiledTitle')}</h1>
|
||||
<p className="hunter-missing-body">
|
||||
{t('hunters.profile.veiledBody', { username })}
|
||||
</p>
|
||||
<Link to="/hunters" className="hunter-missing-link">
|
||||
{t('hunters.backToRoster')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && hunter && (
|
||||
<>
|
||||
<section className="hunter-hero">
|
||||
<div className="hunter-glyph">
|
||||
<GhostGlyph
|
||||
hue={hunter.avatar_hue ?? 150}
|
||||
form={asForm(hunter.avatar_form)}
|
||||
size={148}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="hunter-name">{hunterName(hunter)}</h1>
|
||||
<p className="hunter-handle">@{hunter.username}</p>
|
||||
|
||||
<p className="hunter-rank">
|
||||
{t('profile.rankLine', {
|
||||
title: rankTitle(t, hunter.rank.title),
|
||||
level: hunter.rank.level,
|
||||
})}
|
||||
</p>
|
||||
<div
|
||||
className="hunter-progress"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progressPercent(hunter.rank.progress)}
|
||||
aria-label={t('hunters.profile.progressLabel')}
|
||||
>
|
||||
<div
|
||||
className="hunter-progress-fill"
|
||||
style={{ width: `${progressPercent(hunter.rank.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="hunter-progress-note">
|
||||
{hunter.rank.next_at == null
|
||||
? t('hunters.profile.progressMax')
|
||||
: t('hunters.profile.progressNext', {
|
||||
remaining: Math.max(0, hunter.rank.next_at - hunter.rank.encounters),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<dl className="hunter-stats">
|
||||
<div className="hunter-stat">
|
||||
<dt>{t('hunters.profile.encounters')}</dt>
|
||||
<dd>{hunter.rank.encounters}</dd>
|
||||
</div>
|
||||
{hunter.gender && (
|
||||
<div className="hunter-stat">
|
||||
<dt>{t('profile.gender.label')}</dt>
|
||||
<dd>{genderLabel(t, hunter.gender)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{joined && (
|
||||
<div className="hunter-stat">
|
||||
<dt>{t('hunters.profile.joined')}</dt>
|
||||
<dd>{joined}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{hunter.bio && <blockquote className="hunter-bio">{hunter.bio}</blockquote>}
|
||||
|
||||
{/* Workstream S owns /messages/:username — this is only the door. */}
|
||||
<Link to={`/messages/${hunter.username}`} className="hunter-message-link">
|
||||
{t('hunters.profile.sendMessage')}
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="hunter-recent">
|
||||
<h2 className="hunter-section-title">{t('hunters.profile.recentTitle')}</h2>
|
||||
{hunter.recent_entities.length === 0 ? (
|
||||
<p className="hunter-state">{t('hunters.profile.recentEmpty')}</p>
|
||||
) : (
|
||||
<ul className="hunter-grid">
|
||||
{hunter.recent_entities.map((entity) => (
|
||||
<li key={entity.id} className="hunter-card-item">
|
||||
<Link
|
||||
to={`/codex/${entity.id}`}
|
||||
className="hunter-card"
|
||||
data-rarity={entity.rarity}
|
||||
>
|
||||
<div className="hunter-card-glyph-wrap">
|
||||
<div
|
||||
className="hunter-card-glyph"
|
||||
style={
|
||||
{ '--glyph-hue': entity.visual.hue ?? 150 } as CSSProperties
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hunter-card-form">
|
||||
{t(`codex.form.${asForm(entity.visual.form)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="hunter-card-name">{entity.name}</h3>
|
||||
<p className="hunter-card-epithet">{entity.epithet}</p>
|
||||
<span className="hunter-rarity" data-rarity={entity.rarity}>
|
||||
{t(`codex.rarity.${entity.rarity}`, entity.rarity)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
frontend/src/pages/HuntersPage.css
Normal file
179
frontend/src/pages/HuntersPage.css
Normal file
@@ -0,0 +1,179 @@
|
||||
/* The roll of hunters — roster grid. Codex tokens throughout. */
|
||||
|
||||
.hunters-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 1.6rem clamp(0.9rem, 4vw, 3rem) 5rem;
|
||||
}
|
||||
|
||||
.hunters-header {
|
||||
max-width: 760px;
|
||||
margin: 0 auto 2.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hunters-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.hunters-back:hover,
|
||||
.hunters-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
}
|
||||
|
||||
.hunters-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.9rem, 6vw, 3.2rem);
|
||||
letter-spacing: 0.2em;
|
||||
margin: 0.4rem 0 0.5rem;
|
||||
color: #eef7f0;
|
||||
text-shadow: 0 0 18px rgba(124, 255, 178, 0.32), 0 0 46px rgba(178, 107, 255, 0.24);
|
||||
}
|
||||
|
||||
.hunters-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
}
|
||||
|
||||
.hunters-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 3.5rem 0;
|
||||
animation: hunters-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hunters-state-error {
|
||||
color: #ff3b5c;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hunters-grid {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
max-width: 1180px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.hunters-card-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hunters-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.3rem;
|
||||
min-height: 44px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 1.4rem 0.9rem 1.2rem;
|
||||
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.hunters-card:hover,
|
||||
.hunters-card:focus-visible {
|
||||
outline: none;
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(124, 255, 178, 0.5);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(124, 255, 178, 0.14);
|
||||
}
|
||||
|
||||
.hunters-card .ghost-glyph {
|
||||
animation: hunters-drift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hunters-card-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0.5rem 0 0;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.hunters-card-rank {
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(178, 107, 255, 0.85);
|
||||
}
|
||||
|
||||
.hunters-card-bar {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(124, 255, 178, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hunters-card-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #7cffb2, #b26bff);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.hunters-card-meta {
|
||||
margin: 0;
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
}
|
||||
|
||||
@keyframes hunters-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hunters-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hunters-state,
|
||||
.hunters-card .ghost-glyph {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hunters-card,
|
||||
.hunters-card-bar-fill,
|
||||
.hunters-back {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
107
frontend/src/pages/HuntersPage.tsx
Normal file
107
frontend/src/pages/HuntersPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// The roll of hunters — /hunters. A roster of public profiles from
|
||||
// GET /api/hunters, each card a door to /hunters/:username.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GhostGlyph } from '../components/GhostGlyph'
|
||||
import { usePageMeta } from '../lib/pageMeta'
|
||||
import { progressPercent, rankTitle } from '../lib/hunterLabels'
|
||||
import * as api from '../api'
|
||||
import { hunterName } from '../api'
|
||||
import type { RosterHunter } from '../api'
|
||||
import type { GhostForm } from '../lib/types'
|
||||
import './HuntersPage.css'
|
||||
|
||||
type LoadState = 'loading' | 'error' | 'ready'
|
||||
|
||||
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
|
||||
|
||||
function asForm(form: string | null | undefined): GhostForm {
|
||||
return GHOST_FORMS.includes(form ?? '') ? (form as GhostForm) : 'wisp'
|
||||
}
|
||||
|
||||
export function HuntersPage() {
|
||||
const { t } = useTranslation()
|
||||
const [hunters, setHunters] = useState<RosterHunter[]>([])
|
||||
const [state, setState] = useState<LoadState>('loading')
|
||||
|
||||
usePageMeta({
|
||||
title: 'The Roll of Hunters',
|
||||
description:
|
||||
'Every seeker who has left their name on the veil — their rank, their title, and the spirits they have reached.',
|
||||
path: '/hunters',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
api
|
||||
.fetchHunters(controller.signal)
|
||||
.then((rows) => {
|
||||
setHunters(rows)
|
||||
setState('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState('error')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="hunters-page">
|
||||
<header className="hunters-header">
|
||||
<Link to="/seance" className="hunters-back">
|
||||
{t('hunters.back')}
|
||||
</Link>
|
||||
<h1 className="hunters-title">{t('hunters.title')}</h1>
|
||||
<p className="hunters-subtitle">{t('hunters.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{state === 'loading' && (
|
||||
<p className="hunters-state" role="status">
|
||||
{t('hunters.loading')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<p className="hunters-state hunters-state-error" role="alert">
|
||||
{t('common.error')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'ready' && hunters.length === 0 && (
|
||||
<p className="hunters-state">{t('hunters.empty')}</p>
|
||||
)}
|
||||
|
||||
{state === 'ready' && hunters.length > 0 && (
|
||||
<ul className="hunters-grid">
|
||||
{hunters.map((hunter) => (
|
||||
<li key={hunter.username} className="hunters-card-item">
|
||||
<Link to={`/hunters/${hunter.username}`} className="hunters-card">
|
||||
<GhostGlyph
|
||||
hue={hunter.avatar_hue ?? 150}
|
||||
form={asForm(hunter.avatar_form)}
|
||||
size={72}
|
||||
/>
|
||||
<h2 className="hunters-card-name">{hunterName(hunter)}</h2>
|
||||
<p className="hunters-card-rank">
|
||||
{t('profile.rankLine', {
|
||||
title: rankTitle(t, hunter.rank.title),
|
||||
level: hunter.rank.level,
|
||||
})}
|
||||
</p>
|
||||
<div className="hunters-card-bar" aria-hidden="true">
|
||||
<div
|
||||
className="hunters-card-bar-fill"
|
||||
style={{ width: `${progressPercent(hunter.rank.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="hunters-card-meta">
|
||||
{t('hunters.card.encounters', { count: hunter.rank.encounters })}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
frontend/src/pages/MessagesPage.css
Normal file
154
frontend/src/pages/MessagesPage.css
Normal file
@@ -0,0 +1,154 @@
|
||||
/* Hunter messages — SeancePage token palette. */
|
||||
|
||||
.msg-page {
|
||||
min-height: 100vh;
|
||||
padding: 1.4rem 1rem 2rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
color: #d3e9db;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.msg-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 14px rgba(124, 255, 178, 0.4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.msg-back {
|
||||
color: #b26bff;
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.msg-back:hover { text-shadow: 0 0 12px rgba(178, 107, 255, 0.8); }
|
||||
|
||||
.msg-empty {
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.msg-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem 0.6rem;
|
||||
/* 44px floor: these are the primary tap targets on this screen. */
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid rgba(124, 255, 178, 0.12);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.msg-row:hover { background: rgba(124, 255, 178, 0.05); }
|
||||
|
||||
.msg-row-glyph { flex-shrink: 0; line-height: 0; }
|
||||
.msg-row-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.msg-row-top { display: flex; justify-content: space-between; gap: 0.6rem; }
|
||||
.msg-row-name { color: #7cffb2; font-size: 0.85rem; }
|
||||
.msg-row-when { font-size: 0.7rem; white-space: nowrap; }
|
||||
|
||||
.msg-row-last {
|
||||
font-size: 0.76rem;
|
||||
/* One line only — a long message must not push the row tall. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-unread {
|
||||
flex-shrink: 0;
|
||||
min-width: 20px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(178, 107, 255, 0.25);
|
||||
color: #ecdcff;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- thread ---- */
|
||||
|
||||
.msg-thread-page { display: flex; flex-direction: column; }
|
||||
|
||||
.msg-thread-who { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.msg-thread-name { color: #7cffb2; text-decoration: none; font-size: 0.9rem; }
|
||||
|
||||
.msg-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 82%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
}
|
||||
|
||||
.msg-bubble.is-mine {
|
||||
align-self: flex-end;
|
||||
border-color: rgba(178, 107, 255, 0.4);
|
||||
}
|
||||
|
||||
.msg-bubble.is-theirs { align-self: flex-start; }
|
||||
|
||||
.msg-bubble-body {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
/* Arbitrary text from another hunter: wrap it rather than let a long
|
||||
unbroken string blow out the layout. */
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-bubble-when { font-size: 0.66rem; }
|
||||
|
||||
.msg-compose { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
|
||||
.msg-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
background: rgba(16, 16, 26, 0.9);
|
||||
border: 1px solid rgba(124, 255, 178, 0.25);
|
||||
border-radius: 3px;
|
||||
color: #d3e9db;
|
||||
font-family: inherit;
|
||||
/* 16px exactly: iOS Safari zooms the page for anything smaller and
|
||||
leaves it zoomed after blur. */
|
||||
font-size: 16px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.msg-input:focus { outline: 1px solid rgba(124, 255, 178, 0.6); }
|
||||
|
||||
.msg-compose-row { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
|
||||
.msg-count { font-size: 0.7rem; }
|
||||
.msg-error { color: #ff8fa3; font-size: 0.78rem; margin: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.msg-compose-row button { min-height: 44px; flex: 1 1 auto; }
|
||||
.msg-bubble { max-width: 92%; }
|
||||
}
|
||||
323
frontend/src/pages/MessagesPage.tsx
Normal file
323
frontend/src/pages/MessagesPage.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
// Hunter messages: a conversation list at /messages, one thread at
|
||||
// /messages/:username. Deliberately plain — text only, no attachments, no
|
||||
// editing. The point is that hunters can reach each other, not that this
|
||||
// becomes a chat product.
|
||||
//
|
||||
// Message bodies are rendered as text nodes, never as HTML. They are
|
||||
// arbitrary strings written by other people, so the only safe treatment is
|
||||
// no interpretation at all.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { GhostGlyph } from '../components/GhostGlyph'
|
||||
import { usePageMeta } from '../lib/pageMeta'
|
||||
import type { GhostForm } from '../lib/types'
|
||||
import './MessagesPage.css'
|
||||
|
||||
const BODY_MAX = 1000
|
||||
|
||||
type Avatar = { form: string; hue: number }
|
||||
type Hunter = {
|
||||
username: string
|
||||
display_name: string
|
||||
avatar: Avatar
|
||||
profile_public: boolean
|
||||
is_wanderer: boolean
|
||||
}
|
||||
type Conversation = {
|
||||
hunter: Hunter
|
||||
last_body: string
|
||||
last_at: string
|
||||
last_from_me: boolean
|
||||
unread: number
|
||||
}
|
||||
type ThreadMessage = {
|
||||
id: string
|
||||
body: string
|
||||
from_me: boolean
|
||||
created_at: string
|
||||
read_at: string | null
|
||||
}
|
||||
|
||||
function glyphOf(avatar: Avatar, size: number) {
|
||||
const forms: GhostForm[] = ['wisp', 'banshee', 'fairy', 'shade']
|
||||
const form = (forms as string[]).includes(avatar.form)
|
||||
? (avatar.form as GhostForm)
|
||||
: 'wisp'
|
||||
return <GhostGlyph hue={avatar.hue} form={form} size={size} />
|
||||
}
|
||||
|
||||
/** In-fiction relative time; exact clock times would break the register. */
|
||||
function whenAgo(iso: string, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||
const then = new Date(iso).getTime()
|
||||
if (!Number.isFinite(then)) return ''
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000))
|
||||
if (mins < 1) return t('messages.time.now')
|
||||
if (mins < 60) return t('messages.time.minutes', { count: mins })
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 24) return t('messages.time.hours', { count: hours })
|
||||
return t('messages.time.days', { count: Math.round(hours / 24) })
|
||||
}
|
||||
|
||||
export function MessagesPage() {
|
||||
const { username } = useParams<{ username?: string }>()
|
||||
return username ? <Thread username={username} /> : <Inbox />
|
||||
}
|
||||
|
||||
export default MessagesPage
|
||||
|
||||
function Inbox() {
|
||||
const { t } = useTranslation()
|
||||
const { user, checking } = useAuth()
|
||||
const [state, setState] = useState<
|
||||
{ s: 'loading' } | { s: 'ready'; rows: Conversation[] } | { s: 'error' }
|
||||
>({ s: 'loading' })
|
||||
|
||||
usePageMeta({ title: t('messages.title'), path: '/messages' })
|
||||
|
||||
useEffect(() => {
|
||||
if (checking || !user) return
|
||||
const controller = new AbortController()
|
||||
fetch('/api/messages', { credentials: 'include', signal: controller.signal })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
return r.json() as Promise<{ conversations: Conversation[] }>
|
||||
})
|
||||
.then((d) => setState({ s: 'ready', rows: d.conversations ?? [] }))
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState({ s: 'error' })
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [checking, user])
|
||||
|
||||
if (checking) return <div className="msg-page" />
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<p className="msg-empty">{t('messages.needAccount')}</p>
|
||||
<Link className="msg-back" to="/enter">
|
||||
{t('messages.claimName')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<header className="msg-header">
|
||||
<h1 className="msg-title">{t('messages.title')}</h1>
|
||||
<Link className="msg-back" to="/hunters">
|
||||
{t('messages.findHunters')}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{state.s === 'error' && <p className="msg-empty">{t('common.error')}</p>}
|
||||
{state.s === 'ready' && state.rows.length === 0 && (
|
||||
<p className="msg-empty">{t('messages.emptyInbox')}</p>
|
||||
)}
|
||||
{state.s === 'ready' && state.rows.length > 0 && (
|
||||
<ul className="msg-list">
|
||||
{state.rows.map((c) => (
|
||||
<li key={c.hunter.username}>
|
||||
<Link className="msg-row" to={`/messages/${c.hunter.username}`}>
|
||||
<span className="msg-row-glyph" aria-hidden>
|
||||
{glyphOf(c.hunter.avatar, 34)}
|
||||
</span>
|
||||
<span className="msg-row-body">
|
||||
<span className="msg-row-top">
|
||||
<span className="msg-row-name">{c.hunter.display_name}</span>
|
||||
<span className="msg-row-when dim">{whenAgo(c.last_at, t)}</span>
|
||||
</span>
|
||||
<span className="msg-row-last dim">
|
||||
{c.last_from_me ? t('messages.youPrefix') : ''}
|
||||
{c.last_body}
|
||||
</span>
|
||||
</span>
|
||||
{c.unread > 0 && (
|
||||
<span className="msg-unread" aria-label={t('messages.unread')}>
|
||||
{c.unread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Thread({ username }: { username: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { user, checking } = useAuth()
|
||||
const [hunter, setHunter] = useState<Hunter | null>(null)
|
||||
const [messages, setMessages] = useState<ThreadMessage[] | null>(null)
|
||||
const [canSend, setCanSend] = useState(true)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const endRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
usePageMeta({ title: hunter?.display_name ?? username, path: `/messages/${username}` })
|
||||
|
||||
const load = useCallback(
|
||||
(signal?: AbortSignal) =>
|
||||
fetch(`/api/messages/${encodeURIComponent(username)}`, {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
return r.json() as Promise<{
|
||||
hunter: Hunter
|
||||
messages: ThreadMessage[]
|
||||
can_send: boolean
|
||||
}>
|
||||
})
|
||||
.then((d) => {
|
||||
setHunter(d.hunter)
|
||||
setMessages(d.messages ?? [])
|
||||
setCanSend(d.can_send)
|
||||
}),
|
||||
[username],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (checking || !user) return
|
||||
const controller = new AbortController()
|
||||
load(controller.signal).catch(() => {
|
||||
if (!controller.signal.aborted) setFailed(true)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [checking, user, load])
|
||||
|
||||
// Keep the newest message in view as the thread grows.
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'end' })
|
||||
}, [messages])
|
||||
|
||||
const send = async () => {
|
||||
const body = draft.trim()
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: username, body }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
// 403 is the guest case and deserves its own copy — everything else
|
||||
// is a generic failure the seeker can simply retry.
|
||||
setError(res.status === 403 ? t('messages.guestBlocked') : t('messages.sendFailed'))
|
||||
return
|
||||
}
|
||||
setDraft('')
|
||||
await load()
|
||||
} catch {
|
||||
setError(t('messages.sendFailed'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) return <div className="msg-page" />
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<p className="msg-empty">{t('messages.needAccount')}</p>
|
||||
<Link className="msg-back" to="/enter">
|
||||
{t('messages.claimName')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="msg-page">
|
||||
<p className="msg-empty">{t('messages.noSuchHunter')}</p>
|
||||
<Link className="msg-back" to="/messages">
|
||||
{t('messages.backToInbox')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="msg-page msg-thread-page">
|
||||
<header className="msg-header">
|
||||
<Link className="msg-back" to="/messages">
|
||||
{t('messages.backToInbox')}
|
||||
</Link>
|
||||
{hunter && (
|
||||
<div className="msg-thread-who">
|
||||
<span aria-hidden>{glyphOf(hunter.avatar, 30)}</span>
|
||||
{hunter.profile_public ? (
|
||||
<Link className="msg-thread-name" to={`/hunters/${hunter.username}`}>
|
||||
{hunter.display_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="msg-thread-name">{hunter.display_name}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="msg-thread" role="log" aria-live="polite">
|
||||
{messages?.length === 0 && <p className="msg-empty">{t('messages.emptyThread')}</p>}
|
||||
{messages?.map((m) => (
|
||||
<div key={m.id} className={`msg-bubble ${m.from_me ? 'is-mine' : 'is-theirs'}`}>
|
||||
{/* Text node: message bodies are never interpreted as markup. */}
|
||||
<p className="msg-bubble-body">{m.body}</p>
|
||||
<span className="msg-bubble-when dim">{whenAgo(m.created_at, t)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{canSend ? (
|
||||
<form
|
||||
className="msg-compose"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void send()
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
className="msg-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value.slice(0, BODY_MAX))}
|
||||
placeholder={t('messages.placeholder')}
|
||||
rows={2}
|
||||
aria-label={t('messages.placeholder')}
|
||||
/>
|
||||
<div className="msg-compose-row">
|
||||
<span className="msg-count dim">
|
||||
{draft.length}/{BODY_MAX}
|
||||
</span>
|
||||
<button type="submit" className="panel-action" disabled={!draft.trim() || sending}>
|
||||
{sending ? t('messages.sending') : t('messages.send')}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="msg-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<p className="msg-empty">
|
||||
{t('messages.guestBlocked')}{' '}
|
||||
<Link className="msg-back" to="/enter">
|
||||
{t('messages.claimName')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
393
frontend/src/pages/ProfilePage.css
Normal file
393
frontend/src/pages/ProfilePage.css
Normal file
@@ -0,0 +1,393 @@
|
||||
/* Own-profile editor. Tokens from SeancePage.css: bg #07070d ·
|
||||
panel rgba(16,16,26,0.85) · phosphor #7cffb2 · violet #b26bff ·
|
||||
blood #ff3b5c · border rgba(124,255,178,0.18). Cinzel display,
|
||||
IBM Plex Mono UI. Touch targets never below 44px. */
|
||||
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 1.6rem clamp(0.9rem, 4vw, 3rem) 5rem;
|
||||
}
|
||||
|
||||
/* ---- header ---- */
|
||||
|
||||
.profile-header {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.profile-back:hover,
|
||||
.profile-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.8rem, 6vw, 2.8rem);
|
||||
letter-spacing: 0.2em;
|
||||
margin: 0.4rem 0 0.5rem;
|
||||
color: #eef7f0;
|
||||
text-shadow: 0 0 18px rgba(124, 255, 178, 0.3), 0 0 44px rgba(178, 107, 255, 0.22);
|
||||
}
|
||||
|
||||
.profile-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
}
|
||||
|
||||
/* ---- wanderer prompt ---- */
|
||||
|
||||
.profile-wanderer {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 1.8rem;
|
||||
padding: 1rem 1.1rem;
|
||||
background: rgba(178, 107, 255, 0.08);
|
||||
border: 1px solid rgba(178, 107, 255, 0.4);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.profile-wanderer-line {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(232, 224, 255, 0.9);
|
||||
}
|
||||
|
||||
.profile-wanderer-claim {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 1.1rem;
|
||||
border: 1px solid rgba(178, 107, 255, 0.6);
|
||||
border-radius: 999px;
|
||||
color: #d9bcff;
|
||||
text-decoration: none;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-wanderer-claim:hover,
|
||||
.profile-wanderer-claim:focus-visible {
|
||||
background: rgba(178, 107, 255, 0.18);
|
||||
color: #f1e6ff;
|
||||
}
|
||||
|
||||
/* ---- states ---- */
|
||||
|
||||
.profile-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 3rem 0;
|
||||
animation: profile-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.profile-state-error {
|
||||
color: #ff3b5c;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- form panels ---- */
|
||||
|
||||
.profile-form {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.3rem;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 1.3rem clamp(0.9rem, 3vw, 1.6rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-panel-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
color: rgba(124, 255, 178, 0.85);
|
||||
}
|
||||
|
||||
.profile-note {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.5);
|
||||
}
|
||||
|
||||
/* ---- glyph preview ---- */
|
||||
|
||||
.profile-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.profile-preview .ghost-glyph {
|
||||
animation: profile-drift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.profile-preview-name {
|
||||
margin: 0.4rem 0 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.profile-preview-rank {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(178, 107, 255, 0.8);
|
||||
}
|
||||
|
||||
/* ---- chips ---- */
|
||||
|
||||
.profile-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-chip {
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
min-height: 44px;
|
||||
padding: 0 1.1rem;
|
||||
color: rgba(216, 230, 220, 0.65);
|
||||
background: rgba(10, 10, 18, 0.9);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.profile-chip:hover {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.profile-chip-active,
|
||||
.profile-chip-active:hover {
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 16px rgba(124, 255, 178, 0.4);
|
||||
}
|
||||
|
||||
/* ---- fields ---- */
|
||||
|
||||
.profile-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.profile-input,
|
||||
.profile-textarea {
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: #eef7f0;
|
||||
background: rgba(7, 7, 13, 0.9);
|
||||
border: 1px solid rgba(124, 255, 178, 0.22);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.profile-input:focus-visible,
|
||||
.profile-textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.2);
|
||||
}
|
||||
|
||||
.profile-hint,
|
||||
.profile-count {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(216, 230, 220, 0.45);
|
||||
}
|
||||
|
||||
.profile-count-over {
|
||||
color: #ff3b5c;
|
||||
}
|
||||
|
||||
.profile-error {
|
||||
font-size: 0.7rem;
|
||||
color: #ff3b5c;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.profile-hue-slider {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
accent-color: #7cffb2;
|
||||
}
|
||||
|
||||
/* ---- toggle ---- */
|
||||
|
||||
.profile-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-toggle input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
accent-color: #7cffb2;
|
||||
}
|
||||
|
||||
.profile-toggle-text {
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #d8e6dc;
|
||||
}
|
||||
|
||||
/* ---- actions ---- */
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.profile-save {
|
||||
font-family: inherit;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
min-height: 44px;
|
||||
padding: 0 1.6rem;
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border: 1px solid #7cffb2;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.profile-save:hover:not(:disabled) {
|
||||
box-shadow: 0 0 20px rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.profile-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-view-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(178, 107, 255, 0.85);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.profile-view-link:hover,
|
||||
.profile-view-link:focus-visible {
|
||||
color: #d9bcff;
|
||||
}
|
||||
|
||||
.profile-saved {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.5);
|
||||
}
|
||||
|
||||
/* ---- motion ---- */
|
||||
|
||||
@keyframes profile-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes profile-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.profile-state,
|
||||
.profile-preview .ghost-glyph {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.profile-chip,
|
||||
.profile-save,
|
||||
.profile-back,
|
||||
.profile-input,
|
||||
.profile-textarea {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
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