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:
@@ -15,6 +15,8 @@ from app.routes.codex import router as codex_router
|
||||
from app.routes.conditions import router as conditions_router
|
||||
from app.routes.device import router as device_router
|
||||
from app.routes.inventory import router as inventory_router
|
||||
from app.routes.messages import router as messages_router
|
||||
from app.routes.profile import router as profile_router
|
||||
from app.routes.seances import router as seances_router
|
||||
from app.routes.seo import router as seo_router
|
||||
from app.routes.shop import router as shop_router
|
||||
@@ -71,6 +73,23 @@ async def lifespan(app: FastAPI):
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
||||
))
|
||||
# Hunter profile columns. All nullable (or defaulted) so existing
|
||||
# rows, guests included, stay valid without a backfill.
|
||||
for column, ddl in (
|
||||
("display_name", "VARCHAR(48)"),
|
||||
("bio", "VARCHAR(280)"),
|
||||
("gender", "VARCHAR(16)"),
|
||||
("avatar_form", "VARCHAR(16)"),
|
||||
("avatar_hue", "INTEGER"),
|
||||
):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {column} {ddl}")
|
||||
)
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||
"profile_public BOOLEAN NOT NULL DEFAULT true"
|
||||
))
|
||||
|
||||
# Defense-in-depth: purchase_unlock() already enforces one row per
|
||||
# (user, unlock_key) via a row-locked check-then-insert, so this
|
||||
# constraint should never actually find a conflict on a live DB.
|
||||
@@ -100,6 +119,8 @@ app.include_router(codex_router)
|
||||
app.include_router(conditions_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(messages_router)
|
||||
app.include_router(profile_router)
|
||||
app.include_router(seances_router)
|
||||
# Registered before the SPA catch-all below, or /robots.txt and
|
||||
# /sitemap.xml would be served index.html instead.
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.event import Event
|
||||
from app.models.inventory_item import InventoryItem
|
||||
from app.models.message import Message
|
||||
from app.models.sigil import Sigil
|
||||
from app.models.unlock import UnlockRecord
|
||||
from app.models.user import User
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"EntitySighting",
|
||||
"Event",
|
||||
"InventoryItem",
|
||||
"Message",
|
||||
"Sigil",
|
||||
"UnlockRecord",
|
||||
"WaitlistEntry",
|
||||
|
||||
49
backend/app/models/message.py
Normal file
49
backend/app/models/message.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
# The wire cap. Enforced in three places on purpose: the Pydantic schema
|
||||
# (rejects with a clear 422/400 before touching the DB), the route's own
|
||||
# check (so a body built any other way still can't slip through), and the
|
||||
# column type below — Text, because Postgres VARCHAR(n) truncation/erroring
|
||||
# is a worse failure mode than a validated length, and a future cap change
|
||||
# then needs no migration.
|
||||
BODY_MAX_CHARS = 1000
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""One plain-text message from one hunter to another.
|
||||
|
||||
Deliberately minimal (hunters-and-messages spec, Workstream S): no
|
||||
attachments, no editing, no groups, no threads-as-rows — a "thread" is
|
||||
simply every row between two user ids, ordered by time.
|
||||
|
||||
`read_at` is null until the *recipient* opens the thread; the sender
|
||||
never marks anything read, so this doubles as the unread signal.
|
||||
"""
|
||||
|
||||
__tablename__ = "messages"
|
||||
# Both directions are queried: the conversation list and thread view each
|
||||
# need "sent by me" OR'd with "sent to me", and the unread aggregate
|
||||
# scans (recipient_id, read_at). The composite indexes below serve those
|
||||
# ordered scans; the per-column indexes on the FKs come from
|
||||
# index=True and keep single-sided lookups cheap.
|
||||
__table_args__ = (
|
||||
Index("ix_messages_sender_recipient_created", "sender_id", "recipient_id", "created_at"),
|
||||
Index("ix_messages_recipient_sender_created", "recipient_id", "sender_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
recipient_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Float, Integer, String
|
||||
from sqlalchemy import Boolean, DateTime, Float, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
@@ -14,6 +14,27 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# --- hunter profile ---------------------------------------------------
|
||||
# All nullable so every pre-existing row — including the guest
|
||||
# `wanderer-` accounts minted by the open door — stays valid without a
|
||||
# backfill. Absent values are treated as "not set" and fall back at the
|
||||
# serialization layer, never here.
|
||||
#
|
||||
# The avatar is procedural: a GhostForm plus a hue, rendered by the same
|
||||
# GhostGlyph component that draws entities. No uploads means no
|
||||
# moderation surface, no EXIF stripping and no blob storage — and if a
|
||||
# real image is ever wanted, an `avatar_url` slots in beside these
|
||||
# without changing anything else.
|
||||
display_name: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
bio: Mapped[str | None] = mapped_column(String(280), nullable=True)
|
||||
gender: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
avatar_form: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
avatar_hue: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Hides the profile from /api/hunters and 404s the public page. It does
|
||||
# NOT stop messages arriving — privacy here is about being browsed, not
|
||||
# about being unreachable.
|
||||
profile_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
essence: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
|
||||
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's
|
||||
|
||||
114
backend/app/rank.py
Normal file
114
backend/app/rank.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""A hunter's rank — pure maths over three numbers already on the User row.
|
||||
|
||||
No DB, no I/O, no clock: `level_for` and `progress_for` are total functions of
|
||||
their arguments, so the API layer can call them on values it already loaded and
|
||||
the tests can exercise absurd inputs without a database.
|
||||
|
||||
The curve
|
||||
---------
|
||||
Everything is converted into one currency, "standing", so the three sources of
|
||||
progress can be compared:
|
||||
|
||||
standing = encounters * ENCOUNTER_WEIGHT
|
||||
+ max(0, essence) // ESSENCE_PER_POINT
|
||||
+ max(0.0, favor) * FAVOR_WEIGHT
|
||||
|
||||
Contact is what the app is *about*, so an encounter is worth ten points while
|
||||
ten essence is worth one — a seeker who only buys unlocks climbs very slowly.
|
||||
Favor is a hidden [-1, 1] score nudged by judgment correctness; it contributes
|
||||
at most a few points, enough to break a tie between two equally-travelled
|
||||
hunters but never enough to be a second progression track. Negative essence and
|
||||
negative favor are floored at zero rather than subtracting, so a hunter can
|
||||
never be *demoted* by a bad judgment — rank is a record of what you have done.
|
||||
|
||||
Thresholds are `0, 10, 40, 100, 220, 450`: level 1 costs exactly one encounter
|
||||
(a new hunter finishes their first séance and immediately sees the bar move —
|
||||
this is the point of the curve), then each step costs roughly 2.2x the last.
|
||||
Geometric growth means the early levels arrive in a single sitting while
|
||||
`oracle` is a genuine long-haul goal (~45 distinct spirits), without a
|
||||
hand-tuned table that has to be re-justified every time a level is added.
|
||||
"""
|
||||
|
||||
# One-word, in-fiction titles, indexed by level.
|
||||
TITLES: tuple[str, ...] = (
|
||||
"curious",
|
||||
"sensitive",
|
||||
"channeler",
|
||||
"medium",
|
||||
"adept",
|
||||
"oracle",
|
||||
)
|
||||
|
||||
# Standing required to *reach* each level; index == level. Strictly increasing.
|
||||
THRESHOLDS: tuple[int, ...] = (0, 10, 40, 100, 220, 450)
|
||||
|
||||
MAX_LEVEL = len(THRESHOLDS) - 1
|
||||
|
||||
ENCOUNTER_WEIGHT = 10
|
||||
ESSENCE_PER_POINT = 10
|
||||
FAVOR_WEIGHT = 5.0
|
||||
|
||||
|
||||
def standing_for(encounters: int, essence: int, favor: float) -> int:
|
||||
"""The single progression currency. Total and non-negative for any input,
|
||||
including negative essence/favor and non-finite favor."""
|
||||
try:
|
||||
enc = max(0, int(encounters))
|
||||
ess = max(0, int(essence))
|
||||
fav = float(favor)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
# NaN fails every comparison, so test for it rather than clamping.
|
||||
if not (fav == fav): # noqa: PLR0124 — NaN check without importing math
|
||||
fav = 0.0
|
||||
fav = min(1.0, max(0.0, fav))
|
||||
return enc * ENCOUNTER_WEIGHT + ess // ESSENCE_PER_POINT + int(fav * FAVOR_WEIGHT)
|
||||
|
||||
|
||||
def level_for(encounters: int, essence: int, favor: float) -> int:
|
||||
"""Highest level whose threshold the hunter's standing has reached,
|
||||
clamped to [0, MAX_LEVEL]."""
|
||||
standing = standing_for(encounters, essence, favor)
|
||||
level = 0
|
||||
for candidate, threshold in enumerate(THRESHOLDS):
|
||||
if standing >= threshold:
|
||||
level = candidate
|
||||
else:
|
||||
break
|
||||
return level
|
||||
|
||||
|
||||
def title_for(level: int) -> str:
|
||||
"""Title for a level, clamped — never raises on an out-of-range level."""
|
||||
return TITLES[min(MAX_LEVEL, max(0, int(level)))]
|
||||
|
||||
|
||||
def progress_for(encounters: int, essence: int, favor: float) -> dict:
|
||||
"""Rank plus the numbers a progress bar needs.
|
||||
|
||||
`next_at` is the standing required for the next level (None at MAX_LEVEL),
|
||||
and `progress` is the 0.0..1.0 fraction of the way there (1.0 at
|
||||
MAX_LEVEL, so a maxed bar renders full rather than empty).
|
||||
"""
|
||||
standing = standing_for(encounters, essence, favor)
|
||||
level = level_for(encounters, essence, favor)
|
||||
floor = THRESHOLDS[level]
|
||||
|
||||
if level >= MAX_LEVEL:
|
||||
next_at: int | None = None
|
||||
progress = 1.0
|
||||
else:
|
||||
next_at = THRESHOLDS[level + 1]
|
||||
span = next_at - floor
|
||||
progress = min(1.0, max(0.0, (standing - floor) / span))
|
||||
|
||||
return {
|
||||
"level": level,
|
||||
"title": title_for(level),
|
||||
# Echoed back so a caller rendering the bar doesn't need a second
|
||||
# source for the count it is labelling.
|
||||
"encounters": max(0, int(encounters)) if isinstance(encounters, (int, float)) else 0,
|
||||
"standing": standing,
|
||||
"next_at": next_at,
|
||||
"progress": round(progress, 4),
|
||||
}
|
||||
@@ -16,6 +16,97 @@ from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["codex"])
|
||||
|
||||
# How many distinct hunters the dossier names. Everyone else is still
|
||||
# counted in `total_encounters` — the roster is a window, not the truth.
|
||||
ROSTER_LIMIT = 20
|
||||
|
||||
|
||||
def _user_column(name: str):
|
||||
"""The profile columns land in `users` via a parallel workstream (the
|
||||
hunter-profiles spec §"Data model"). Until that migration exists this
|
||||
module must not reference them in SQL at all, or every Codex request
|
||||
500s on an unknown column. Resolve them by name and fall back to a
|
||||
literal default, so the same code path works before and after.
|
||||
"""
|
||||
return getattr(User, name, None)
|
||||
|
||||
|
||||
def _hunter_entry(
|
||||
username: str,
|
||||
display_name: str | None,
|
||||
avatar_form: str | None,
|
||||
avatar_hue: int | None,
|
||||
profile_public: bool | None,
|
||||
times: int,
|
||||
last_seen,
|
||||
) -> dict:
|
||||
# A hidden profile is still a hunter who was there: counted, named
|
||||
# nowhere. `public` is what the UI keys the link off.
|
||||
public = True if profile_public is None else bool(profile_public)
|
||||
return {
|
||||
"username": username,
|
||||
"display_name": display_name or username,
|
||||
"avatar_form": avatar_form,
|
||||
"avatar_hue": avatar_hue,
|
||||
"public": public,
|
||||
"times_contacted": times,
|
||||
"last_seen": last_seen.isoformat() if last_seen else None,
|
||||
}
|
||||
|
||||
|
||||
async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]:
|
||||
"""The roster of hunters who have contacted this spirit, newest-first.
|
||||
|
||||
ONE aggregate query — group the sightings by hunter and carry the count
|
||||
and latest contact out of the same scan. Never a lookup per hunter.
|
||||
"""
|
||||
display_name = _user_column("display_name")
|
||||
avatar_form = _user_column("avatar_form")
|
||||
avatar_hue = _user_column("avatar_hue")
|
||||
profile_public = _user_column("profile_public")
|
||||
|
||||
last_seen = func.max(EntitySighting.seen_at).label("last_seen")
|
||||
times = func.count(EntitySighting.id).label("times")
|
||||
columns = [User.username, times, last_seen]
|
||||
optional = [display_name, avatar_form, avatar_hue, profile_public]
|
||||
columns.extend(col for col in optional if col is not None)
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(*columns)
|
||||
.join(User, User.id == EntitySighting.user_id)
|
||||
.where(EntitySighting.entity_id == entity_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc(last_seen))
|
||||
.limit(ROSTER_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
roster = [
|
||||
_hunter_entry(
|
||||
row.username,
|
||||
getattr(row, "display_name", None) if display_name is not None else None,
|
||||
getattr(row, "avatar_form", None) if avatar_form is not None else None,
|
||||
getattr(row, "avatar_hue", None) if avatar_hue is not None else None,
|
||||
getattr(row, "profile_public", None) if profile_public is not None else None,
|
||||
row.times,
|
||||
row.last_seen,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Distinct hunters, not sighting rows (`sightings` already reports those)
|
||||
# — so the UI can honestly say "and N more" past the roster window.
|
||||
total = (
|
||||
await db.scalar(
|
||||
select(func.count(func.distinct(EntitySighting.user_id))).where(
|
||||
EntitySighting.entity_id == entity_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return roster, total
|
||||
|
||||
|
||||
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
|
||||
return {
|
||||
@@ -72,17 +163,45 @@ async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
|
||||
|
||||
discoverer = None
|
||||
discoverer_entry = None
|
||||
if entity.discovered_by:
|
||||
user = await db.get(User, entity.discovered_by)
|
||||
discoverer = user.username if user else None
|
||||
if user is not None:
|
||||
discoverer = user.username
|
||||
discoverer_entry = _hunter_entry(
|
||||
user.username,
|
||||
getattr(user, "display_name", None),
|
||||
getattr(user, "avatar_form", None),
|
||||
getattr(user, "avatar_hue", None),
|
||||
getattr(user, "profile_public", None),
|
||||
0,
|
||||
None,
|
||||
)
|
||||
|
||||
sightings = await db.scalar(
|
||||
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
|
||||
)
|
||||
roster, total_encounters = await _encounters(db, entity.id)
|
||||
|
||||
card = _entity_card(entity, discoverer)
|
||||
card["persona"] = entity.persona
|
||||
card["voice"] = entity.voice_profile
|
||||
card["sightings"] = sightings or 0
|
||||
# The summoner, with enough to render a glyph and decide on a link.
|
||||
# `times_contacted`/`last_seen` come from their roster row when they
|
||||
# have one — discovery alone doesn't imply a surviving sighting row.
|
||||
if discoverer_entry is not None:
|
||||
for hunter in roster:
|
||||
if hunter["username"] == discoverer_entry["username"]:
|
||||
discoverer_entry["times_contacted"] = hunter["times_contacted"]
|
||||
discoverer_entry["last_seen"] = hunter["last_seen"]
|
||||
break
|
||||
card["discoverer"] = discoverer_entry
|
||||
card["discoverer_public"] = (
|
||||
discoverer_entry["public"] if discoverer_entry else False
|
||||
)
|
||||
card["encounters"] = roster
|
||||
card["total_encounters"] = total_encounters
|
||||
return card
|
||||
|
||||
|
||||
|
||||
254
backend/app/routes/messages.py
Normal file
254
backend/app/routes/messages.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Whispers between hunters — plain text, no attachments, no editing, no
|
||||
groups. Workstream S of docs/superpowers/specs/
|
||||
2026-07-30-hunters-and-messages-design.md.
|
||||
|
||||
Security note, because it is the whole point of this module: every query is
|
||||
scoped to the caller's own user id. A "thread" is not a row anybody can
|
||||
address by id — it is derived from (sender_id, recipient_id) pairs where one
|
||||
side is always `user.id`, so there is no id a caller could guess to read
|
||||
somebody else's mail. See tests/test_messages.py's cross-user leak tests.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import case, desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.message import BODY_MAX_CHARS, Message
|
||||
from app.models.user import User
|
||||
from app.routes.auth import WANDERER_PREFIX
|
||||
from app.rate_limit import RateLimiter
|
||||
from app.schemas import MessageIn
|
||||
|
||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
||||
|
||||
# Per-user, not per-IP: sending requires an account, so the user id is the
|
||||
# real actor. 20 an hour is plenty for conversation and useless for spam.
|
||||
send_limiter = RateLimiter(max_requests=20, window_seconds=3600)
|
||||
|
||||
CONVERSATION_LIMIT = 50
|
||||
THREAD_LIMIT_DEFAULT = 50
|
||||
THREAD_LIMIT_MAX = 100
|
||||
EXCERPT_CHARS = 160
|
||||
|
||||
AVATAR_FORMS = ("wisp", "banshee", "fairy", "shade")
|
||||
|
||||
|
||||
def _hunter_out(user: User) -> dict:
|
||||
"""Public identity of a correspondent.
|
||||
|
||||
Workstream P owns `display_name` / `avatar_form` / `avatar_hue` /
|
||||
`profile_public` and lands separately, so every one is read with
|
||||
`getattr(..., None)`: this module works identically before and after
|
||||
that merge, and the mailbox never depends on a profile being public —
|
||||
privacy hides the profile page, not the mailbox.
|
||||
"""
|
||||
form = getattr(user, "avatar_form", None)
|
||||
hue = getattr(user, "avatar_hue", None)
|
||||
return {
|
||||
"username": user.username,
|
||||
"display_name": getattr(user, "display_name", None) or user.username,
|
||||
"avatar": {
|
||||
"form": form if form in AVATAR_FORMS else "wisp",
|
||||
"hue": hue if isinstance(hue, int) and 0 <= hue <= 359 else 150,
|
||||
},
|
||||
# False only when Workstream P exists AND the hunter opted out; the
|
||||
# UI uses this purely to decide whether to link to their profile.
|
||||
"profile_public": getattr(user, "profile_public", True) is not False,
|
||||
"is_wanderer": user.username.startswith(WANDERER_PREFIX),
|
||||
}
|
||||
|
||||
|
||||
def _message_out(message: Message, me_id) -> dict:
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"body": message.body,
|
||||
"from_me": message.sender_id == me_id,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"read_at": message.read_at.isoformat() if message.read_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _unread_by_sender(db: AsyncSession, me_id) -> dict:
|
||||
"""ONE aggregate query for every unread count — never a lookup per
|
||||
conversation. Keyed by the sender's user id."""
|
||||
rows = await db.execute(
|
||||
select(Message.sender_id, func.count(Message.id))
|
||||
.where(Message.recipient_id == me_id, Message.read_at.is_(None))
|
||||
.group_by(Message.sender_id)
|
||||
)
|
||||
return {sender_id: count for sender_id, count in rows}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(
|
||||
payload: MessageIn,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# The one deliberate account-only capability besides device pairing: a
|
||||
# wanderer's name is temporary and unrecoverable, so a reply would have
|
||||
# nowhere to land. They can still RECEIVE.
|
||||
if user.username.startswith(WANDERER_PREFIX):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"a wanderer has no name to sign — claim one before you whisper",
|
||||
)
|
||||
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "an empty whisper carries nothing")
|
||||
if len(body) > BODY_MAX_CHARS:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"the veil will not carry more than {BODY_MAX_CHARS} characters at once",
|
||||
)
|
||||
|
||||
to = payload.to.strip()
|
||||
if to.lower() == user.username.lower():
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
|
||||
)
|
||||
|
||||
recipient = await db.scalar(select(User).where(User.username == to))
|
||||
if recipient is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
|
||||
# Guard the id comparison too: the username check above is the friendly
|
||||
# path, this one is what actually makes self-messaging impossible.
|
||||
if recipient.id == user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
|
||||
)
|
||||
|
||||
if not send_limiter.allow(str(user.id)):
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"you have whispered enough for one hour — let the veil settle",
|
||||
)
|
||||
|
||||
message = Message(sender_id=user.id, recipient_id=recipient.id, body=body)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return {"message": _message_out(message, user.id), "to": _hunter_out(recipient)}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_conversations(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Every correspondent, their last message excerpt, and unread counts.
|
||||
|
||||
Two queries total regardless of how many conversations exist: one
|
||||
window-function pass for the newest message per correspondent, and one
|
||||
GROUP BY for unread counts.
|
||||
"""
|
||||
correspondent = case(
|
||||
(Message.sender_id == user.id, Message.recipient_id), else_=Message.sender_id
|
||||
)
|
||||
ranked = (
|
||||
select(
|
||||
Message.id,
|
||||
Message.sender_id,
|
||||
Message.body,
|
||||
Message.created_at,
|
||||
Message.read_at,
|
||||
correspondent.label("correspondent_id"),
|
||||
func.row_number()
|
||||
.over(partition_by=correspondent, order_by=desc(Message.created_at))
|
||||
.label("rn"),
|
||||
)
|
||||
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id))
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
ranked.c.sender_id,
|
||||
ranked.c.body,
|
||||
ranked.c.created_at,
|
||||
ranked.c.read_at,
|
||||
User,
|
||||
)
|
||||
.join(User, User.id == ranked.c.correspondent_id)
|
||||
.where(ranked.c.rn == 1)
|
||||
.order_by(desc(ranked.c.created_at))
|
||||
.limit(CONVERSATION_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
unread = await _unread_by_sender(db, user.id)
|
||||
|
||||
conversations = [
|
||||
{
|
||||
"hunter": _hunter_out(other),
|
||||
"excerpt": body[:EXCERPT_CHARS],
|
||||
"truncated": len(body) > EXCERPT_CHARS,
|
||||
"last_at": created_at.isoformat(),
|
||||
"last_from_me": sender_id == user.id,
|
||||
"unread": unread.get(other.id, 0),
|
||||
}
|
||||
for sender_id, body, created_at, read_at, other in rows
|
||||
]
|
||||
return {
|
||||
"conversations": conversations,
|
||||
# Exposed here on purpose rather than on GET /api/profile/me, so the
|
||||
# badge does not depend on Workstream P.
|
||||
"unread_total": sum(unread.values()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{username}")
|
||||
async def read_thread(
|
||||
username: str,
|
||||
limit: int = Query(default=THREAD_LIMIT_DEFAULT, ge=1, le=THREAD_LIMIT_MAX),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""The thread with one hunter, newest LAST (reading order), capped at
|
||||
`limit` most-recent messages. Marks the caller's inbound messages read."""
|
||||
other = await db.scalar(select(User).where(User.username == username))
|
||||
if other is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
|
||||
if other.id == user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
|
||||
)
|
||||
|
||||
# Both legs are pinned to (user.id, other.id) in both orders — no third
|
||||
# party's rows can match, whatever `username` is.
|
||||
scope = or_(
|
||||
(Message.sender_id == user.id) & (Message.recipient_id == other.id),
|
||||
(Message.sender_id == other.id) & (Message.recipient_id == user.id),
|
||||
)
|
||||
total = await db.scalar(select(func.count(Message.id)).where(scope)) or 0
|
||||
newest = (
|
||||
await db.execute(
|
||||
select(Message).where(scope).order_by(desc(Message.created_at)).limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
messages = list(reversed(newest))
|
||||
|
||||
# One UPDATE marks everything they sent us read — done after reading the
|
||||
# rows so the response still shows the read_at the caller had on open.
|
||||
await db.execute(
|
||||
update(Message)
|
||||
.where(
|
||||
Message.recipient_id == user.id,
|
||||
Message.sender_id == other.id,
|
||||
Message.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"hunter": _hunter_out(other),
|
||||
"messages": [_message_out(m, user.id) for m in messages],
|
||||
"total": total,
|
||||
"has_more": total > len(messages),
|
||||
"can_send": not user.username.startswith(WANDERER_PREFIX),
|
||||
}
|
||||
278
backend/app/routes/profile.py
Normal file
278
backend/app/routes/profile.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Hunter profiles: the identity an account buys you.
|
||||
|
||||
The open door means a séance needs no account at all, so registering has to
|
||||
be worth something on its own. This is that something — a name other hunters
|
||||
see, a rank that grows from what you have actually contacted, and a public
|
||||
page. Device pairing and messages are the other two.
|
||||
|
||||
Privacy rules that the tests pin, because they are easy to regress:
|
||||
|
||||
- `email` is returned by GET /api/profile/me and by NOTHING else. It
|
||||
exists for account recovery; it is never part of a public payload.
|
||||
- `profile_public = False` makes the public page 404 and drops the hunter
|
||||
from the roster. It deliberately does NOT stop messages arriving —
|
||||
privacy here is about being browsed, not about being unreachable.
|
||||
- Guests (`wanderer-` accounts) have profiles like anyone else. They are
|
||||
real rows; nothing here special-cases them.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import rank
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.user import User
|
||||
from app.rate_limit import RateLimiter
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["profile"])
|
||||
|
||||
# Editing a profile is cheap, but it is still a write — enough headroom for
|
||||
# someone fiddling with a hue slider, tight enough to be uninteresting to
|
||||
# abuse.
|
||||
profile_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
||||
|
||||
GENDERS = {"male", "female", "unspecified"}
|
||||
AVATAR_FORMS = {"wisp", "banshee", "fairy", "shade"}
|
||||
BIO_MAX = 280
|
||||
DISPLAY_NAME_MAX = 48
|
||||
HUE_MAX = 359
|
||||
ROSTER_LIMIT = 24
|
||||
RECENT_ENTITIES = 12
|
||||
|
||||
# Same shape as routes/shop.py's — deliberately permissive, since this is a
|
||||
# recovery hint rather than a verified identity.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$")
|
||||
|
||||
|
||||
async def _encounter_count(db: AsyncSession, user_id) -> int:
|
||||
"""Distinct spirits this hunter has contacted."""
|
||||
return int(
|
||||
await db.scalar(
|
||||
select(func.count(func.distinct(EntitySighting.entity_id))).where(
|
||||
EntitySighting.user_id == user_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _rank_block(encounters: int, user: User) -> dict:
|
||||
return rank.progress_for(encounters, user.essence or 0, user.favor or 0.0)
|
||||
|
||||
|
||||
def _avatar(user: User) -> dict:
|
||||
return {
|
||||
"avatar_form": user.avatar_form,
|
||||
"avatar_hue": user.avatar_hue,
|
||||
}
|
||||
|
||||
|
||||
def _public_fields(user: User) -> dict:
|
||||
"""Everything safe to show a stranger. Note the absence of `email` — that
|
||||
omission is the point of this helper existing."""
|
||||
return {
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"bio": user.bio,
|
||||
"gender": user.gender or "unspecified",
|
||||
**_avatar(user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile/me")
|
||||
async def my_profile(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"profile_public": bool(user.profile_public),
|
||||
# Own profile only.
|
||||
"email": user.email,
|
||||
"rank": _rank_block(encounters, user),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/profile")
|
||||
async def update_profile(
|
||||
payload: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Partial update: only keys actually present are touched, so a client
|
||||
can send one field without having to echo the rest back."""
|
||||
if not profile_limiter.allow(str(user.id)):
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS, "too many changes — slow down"
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "expected an object")
|
||||
|
||||
def _optional_text(key: str, limit: int) -> str | None:
|
||||
"""Trim, cap, and treat empty string as an explicit clear."""
|
||||
raw = payload[key]
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, str):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, f"{key} must be text"
|
||||
)
|
||||
text_value = raw.strip()
|
||||
if len(text_value) > limit:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
f"{key} must be {limit} characters or fewer",
|
||||
)
|
||||
return text_value or None
|
||||
|
||||
if "display_name" in payload:
|
||||
user.display_name = _optional_text("display_name", DISPLAY_NAME_MAX)
|
||||
if "bio" in payload:
|
||||
user.bio = _optional_text("bio", BIO_MAX)
|
||||
|
||||
if "gender" in payload:
|
||||
gender = payload["gender"]
|
||||
if gender not in GENDERS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such gender option"
|
||||
)
|
||||
user.gender = gender
|
||||
|
||||
if "avatar_form" in payload:
|
||||
form = payload["avatar_form"]
|
||||
if form not in AVATAR_FORMS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such sigil form"
|
||||
)
|
||||
user.avatar_form = form
|
||||
|
||||
if "avatar_hue" in payload:
|
||||
hue = payload["avatar_hue"]
|
||||
# bool is an int subclass in Python — reject it explicitly so
|
||||
# `avatar_hue: true` can't silently become hue 1.
|
||||
if not isinstance(hue, int) or isinstance(hue, bool) or not (0 <= hue <= HUE_MAX):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, f"hue must be 0-{HUE_MAX}"
|
||||
)
|
||||
user.avatar_hue = hue
|
||||
|
||||
if "profile_public" in payload:
|
||||
visible = payload["profile_public"]
|
||||
if not isinstance(visible, bool):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "profile_public must be true/false"
|
||||
)
|
||||
user.profile_public = visible
|
||||
|
||||
if "email" in payload:
|
||||
email = payload["email"]
|
||||
if email is None or (isinstance(email, str) and not email.strip()):
|
||||
user.email = None
|
||||
elif isinstance(email, str) and _EMAIL_RE.match(email.strip().lower()):
|
||||
user.email = email.strip().lower()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"profile_public": bool(user.profile_public),
|
||||
"email": user.email,
|
||||
"rank": _rank_block(encounters, user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hunters")
|
||||
async def hunter_roster(db: AsyncSession = Depends(get_db)):
|
||||
"""Public hunters, most-travelled first.
|
||||
|
||||
Encounter counts come from ONE grouped query rather than a lookup per
|
||||
hunter — the roster is the most-hit public endpoint here and an N+1
|
||||
would show immediately.
|
||||
"""
|
||||
counts_q = (
|
||||
select(
|
||||
EntitySighting.user_id,
|
||||
func.count(func.distinct(EntitySighting.entity_id)).label("n"),
|
||||
)
|
||||
.group_by(EntitySighting.user_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(User, func.coalesce(counts_q.c.n, 0).label("encounters"))
|
||||
.outerjoin(counts_q, counts_q.c.user_id == User.id)
|
||||
.where(User.profile_public.is_(True))
|
||||
.order_by(func.coalesce(counts_q.c.n, 0).desc(), User.created_at)
|
||||
.limit(ROSTER_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"hunters": [
|
||||
{
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
**_avatar(user),
|
||||
"rank": _rank_block(int(encounters or 0), user),
|
||||
}
|
||||
for user, encounters in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hunters/{username}")
|
||||
async def hunter_profile(username: str, db: AsyncSession = Depends(get_db)):
|
||||
user = await db.scalar(select(User).where(User.username == username))
|
||||
# A hidden profile is indistinguishable from one that never existed —
|
||||
# confirming the account exists would leak exactly what hiding it was
|
||||
# meant to prevent.
|
||||
if user is None or not user.profile_public:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such hunter walks here")
|
||||
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
|
||||
# Most recently contacted spirits, distinct, newest first.
|
||||
latest = (
|
||||
select(
|
||||
EntitySighting.entity_id,
|
||||
func.max(EntitySighting.seen_at).label("last_seen"),
|
||||
)
|
||||
.where(EntitySighting.user_id == user.id)
|
||||
.group_by(EntitySighting.entity_id)
|
||||
.subquery()
|
||||
)
|
||||
recent = (
|
||||
await db.execute(
|
||||
select(Entity, latest.c.last_seen)
|
||||
.join(latest, latest.c.entity_id == Entity.id)
|
||||
.order_by(latest.c.last_seen.desc())
|
||||
.limit(RECENT_ENTITIES)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"joined_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"rank": _rank_block(encounters, user),
|
||||
"recent_entities": [
|
||||
{
|
||||
"id": str(entity.id),
|
||||
"name": entity.name,
|
||||
"epithet": entity.epithet,
|
||||
"rarity": entity.rarity_tier,
|
||||
"visual": entity.visual_profile or {},
|
||||
}
|
||||
for entity, _seen in recent
|
||||
],
|
||||
}
|
||||
@@ -59,3 +59,13 @@ class SigilOut(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MessageIn(BaseModel):
|
||||
"""A whisper sent from one hunter to another (Workstream S)."""
|
||||
|
||||
# Length rules live in routes/messages.py, not here: an empty or
|
||||
# oversized body is a 400 with in-fiction copy the UI can show, which
|
||||
# is friendlier than Pydantic's 422 validation envelope.
|
||||
to: str
|
||||
body: str
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _make_entity(name="Vesper Wren", rarity="rare", signature="abc123def4567890"):
|
||||
@@ -81,3 +85,112 @@ async def test_stats_counts_veil_activity(client, db_session):
|
||||
async def test_codex_is_public_without_auth(client):
|
||||
assert (await client.get("/api/codex")).status_code == 200
|
||||
assert (await client.get("/api/stats")).status_code == 200
|
||||
|
||||
|
||||
# --- the encounter record: who summoned a spirit, and who else has met it ---
|
||||
# (hunter-profiles wave). The data already existed — every contact is an
|
||||
# EntitySighting row, discovery is Entity.discovered_by.
|
||||
|
||||
|
||||
async def _hunter(db_session, username: str, **profile):
|
||||
"""A hunter. `profile` fields (display_name, profile_public, …) are set
|
||||
only if the parallel profile migration has landed, so these tests pass
|
||||
either way."""
|
||||
user = User(username=username, password_hash="x")
|
||||
for key, value in profile.items():
|
||||
if hasattr(User, key):
|
||||
setattr(user, key, value)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _contact(db_session, entity, user, seen_at):
|
||||
session = ContactSession(user_id=user.id, entity_id=entity.id, mode="wire")
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
EntitySighting(
|
||||
entity_id=entity.id,
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
seen_at=seen_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_surfaces_the_discoverer(client, db_session):
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
summoner = await _hunter(db_session, "mora", display_name="Mora Vane")
|
||||
await db_session.flush()
|
||||
entity.discovered_by = summoner.id
|
||||
await _contact(
|
||||
db_session, entity, summoner, datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["discovered_by"] == "mora"
|
||||
assert body["discoverer"]["username"] == "mora"
|
||||
assert body["discoverer"]["times_contacted"] == 1
|
||||
assert body["discoverer"]["last_seen"] is not None
|
||||
# Public by default, so the dossier may link to their hunter page.
|
||||
assert body["discoverer_public"] is True
|
||||
assert body["discoverer"]["display_name"] in {"mora", "Mora Vane"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encounter_roster_aggregates_per_hunter(client, db_session):
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
twice = await _hunter(db_session, "reva")
|
||||
once = await _hunter(db_session, "isolde")
|
||||
await _contact(db_session, entity, twice, datetime(2026, 7, 1, tzinfo=timezone.utc))
|
||||
await _contact(db_session, entity, twice, datetime(2026, 7, 2, tzinfo=timezone.utc))
|
||||
await _contact(db_session, entity, once, datetime(2026, 7, 9, tzinfo=timezone.utc))
|
||||
await db_session.commit()
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["sightings"] == 3
|
||||
# Distinct hunters, not sighting rows.
|
||||
assert body["total_encounters"] == 2
|
||||
roster = body["encounters"]
|
||||
assert [h["username"] for h in roster] == ["isolde", "reva"] # newest first
|
||||
by_name = {h["username"]: h for h in roster}
|
||||
assert by_name["reva"]["times_contacted"] == 2
|
||||
assert by_name["isolde"]["times_contacted"] == 1
|
||||
assert by_name["reva"]["last_seen"].startswith("2026-07-02")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_hunters_are_counted_but_not_linkable(client, db_session):
|
||||
if not hasattr(User, "profile_public"):
|
||||
pytest.skip("profile_public lands with the profile workstream")
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
hidden = await _hunter(db_session, "nocturne", profile_public=False)
|
||||
seen = await _hunter(db_session, "calla", profile_public=True)
|
||||
await _contact(db_session, entity, hidden, datetime(2026, 7, 3, tzinfo=timezone.utc))
|
||||
await _contact(db_session, entity, seen, datetime(2026, 7, 4, tzinfo=timezone.utc))
|
||||
await db_session.commit()
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["total_encounters"] == 2
|
||||
flags = {h["username"]: h["public"] for h in body["encounters"]}
|
||||
assert flags == {"nocturne": False, "calla": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmet_spirit_has_an_empty_roster(client, db_session):
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(entity)
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["encounters"] == []
|
||||
assert body["total_encounters"] == 0
|
||||
assert body["discoverer"] is None
|
||||
assert body["discoverer_public"] is False
|
||||
|
||||
@@ -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