diff --git a/backend/app/main.py b/backend/app/main.py index a64957a..ed2aad9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c1c2369..ec024bf 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..f05896b --- /dev/null +++ b/backend/app/models/message.py @@ -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 + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4faae46..03c9c57 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/rank.py b/backend/app/rank.py new file mode 100644 index 0000000..9af28b8 --- /dev/null +++ b/backend/app/rank.py @@ -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), + } diff --git a/backend/app/routes/codex.py b/backend/app/routes/codex.py index 428b526..f89ab93 100644 --- a/backend/app/routes/codex.py +++ b/backend/app/routes/codex.py @@ -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 diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py new file mode 100644 index 0000000..55ef4a1 --- /dev/null +++ b/backend/app/routes/messages.py @@ -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), + } diff --git a/backend/app/routes/profile.py b/backend/app/routes/profile.py new file mode 100644 index 0000000..e65a451 --- /dev/null +++ b/backend/app/routes/profile.py @@ -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 + ], + } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b91d3d8..5116b68 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/tests/test_codex.py b/backend/tests/test_codex.py index bb9ecc8..0dcb6d2 100644 --- a/backend/tests/test_codex.py +++ b/backend/tests/test_codex.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c145827..0d0fcdd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 585b068..9a3a4f2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { export function me(): Promise { return request('/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 { + return request('/api/profile/me', { signal }) +} + +export function saveProfile(patch: ProfileUpdate): Promise { + return request('/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 { + 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 { + 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 +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index eacaeec..b56a802 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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" + } } } diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 5b92a8c..22d88c7 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -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" + } } } diff --git a/frontend/src/lib/hunterLabels.ts b/frontend/src/lib/hunterLabels.ts new file mode 100644 index 0000000..b1ccddc --- /dev/null +++ b/frontend/src/lib/hunterLabels.ts @@ -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) +} diff --git a/frontend/src/pages/HunterPage.css b/frontend/src/pages/HunterPage.css new file mode 100644 index 0000000..d6b3dd4 --- /dev/null +++ b/frontend/src/pages/HunterPage.css @@ -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; + } +} diff --git a/frontend/src/pages/HunterPage.tsx b/frontend/src/pages/HunterPage.tsx new file mode 100644 index 0000000..96d1b34 --- /dev/null +++ b/frontend/src/pages/HunterPage.tsx @@ -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(null) + const [state, setState] = useState('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 ( +
+
+ + {t('hunters.backToRoster')} + +
+ + {state === 'loading' && ( +

+ {t('hunters.profile.loading')} +

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

+ {t('common.error')} +

+ )} + {state === 'missing' && ( +
+

{t('hunters.profile.veiledTitle')}

+

+ {t('hunters.profile.veiledBody', { username })} +

+ + {t('hunters.backToRoster')} + +
+ )} + + {state === 'ready' && hunter && ( + <> +
+
+ +
+

{hunterName(hunter)}

+

@{hunter.username}

+ +

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

+
+
+
+

+ {hunter.rank.next_at == null + ? t('hunters.profile.progressMax') + : t('hunters.profile.progressNext', { + remaining: Math.max(0, hunter.rank.next_at - hunter.rank.encounters), + })} +

+ +
+
+
{t('hunters.profile.encounters')}
+
{hunter.rank.encounters}
+
+ {hunter.gender && ( +
+
{t('profile.gender.label')}
+
{genderLabel(t, hunter.gender)}
+
+ )} + {joined && ( +
+
{t('hunters.profile.joined')}
+
{joined}
+
+ )} +
+ + {hunter.bio &&
{hunter.bio}
} + + {/* Workstream S owns /messages/:username — this is only the door. */} + + {t('hunters.profile.sendMessage')} + +
+ +
+

{t('hunters.profile.recentTitle')}

+ {hunter.recent_entities.length === 0 ? ( +

{t('hunters.profile.recentEmpty')}

+ ) : ( +
    + {hunter.recent_entities.map((entity) => ( +
  • + +
    + +

    {entity.name}

    +

    {entity.epithet}

    + + {t(`codex.rarity.${entity.rarity}`, entity.rarity)} + + +
  • + ))} +
+ )} +
+ + )} +
+ ) +} diff --git a/frontend/src/pages/HuntersPage.css b/frontend/src/pages/HuntersPage.css new file mode 100644 index 0000000..0871a06 --- /dev/null +++ b/frontend/src/pages/HuntersPage.css @@ -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; + } +} diff --git a/frontend/src/pages/HuntersPage.tsx b/frontend/src/pages/HuntersPage.tsx new file mode 100644 index 0000000..a4dc14d --- /dev/null +++ b/frontend/src/pages/HuntersPage.tsx @@ -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([]) + const [state, setState] = useState('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 ( +
+
+ + {t('hunters.back')} + +

{t('hunters.title')}

+

{t('hunters.subtitle')}

+
+ + {state === 'loading' && ( +

+ {t('hunters.loading')} +

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

+ {t('common.error')} +

+ )} + {state === 'ready' && hunters.length === 0 && ( +

{t('hunters.empty')}

+ )} + + {state === 'ready' && hunters.length > 0 && ( +
    + {hunters.map((hunter) => ( +
  • + + +

    {hunterName(hunter)}

    +

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

    +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/MessagesPage.css b/frontend/src/pages/MessagesPage.css new file mode 100644 index 0000000..915f728 --- /dev/null +++ b/frontend/src/pages/MessagesPage.css @@ -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%; } +} diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx new file mode 100644 index 0000000..fd29466 --- /dev/null +++ b/frontend/src/pages/MessagesPage.tsx @@ -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 +} + +/** In-fiction relative time; exact clock times would break the register. */ +function whenAgo(iso: string, t: (k: string, o?: Record) => 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 ? : +} + +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
+ if (!user) { + return ( +
+

{t('messages.needAccount')}

+ + {t('messages.claimName')} + +
+ ) + } + + return ( +
+
+

{t('messages.title')}

+ + {t('messages.findHunters')} + +
+ + {state.s === 'error' &&

{t('common.error')}

} + {state.s === 'ready' && state.rows.length === 0 && ( +

{t('messages.emptyInbox')}

+ )} + {state.s === 'ready' && state.rows.length > 0 && ( +
    + {state.rows.map((c) => ( +
  • + + + {glyphOf(c.hunter.avatar, 34)} + + + + {c.hunter.display_name} + {whenAgo(c.last_at, t)} + + + {c.last_from_me ? t('messages.youPrefix') : ''} + {c.last_body} + + + {c.unread > 0 && ( + + {c.unread} + + )} + +
  • + ))} +
+ )} +
+ ) +} + +function Thread({ username }: { username: string }) { + const { t } = useTranslation() + const { user, checking } = useAuth() + const [hunter, setHunter] = useState(null) + const [messages, setMessages] = useState(null) + const [canSend, setCanSend] = useState(true) + const [draft, setDraft] = useState('') + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) + const [failed, setFailed] = useState(false) + const endRef = useRef(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
+ if (!user) { + return ( +
+

{t('messages.needAccount')}

+ + {t('messages.claimName')} + +
+ ) + } + if (failed) { + return ( +
+

{t('messages.noSuchHunter')}

+ + {t('messages.backToInbox')} + +
+ ) + } + + return ( +
+
+ + {t('messages.backToInbox')} + + {hunter && ( +
+ {glyphOf(hunter.avatar, 30)} + {hunter.profile_public ? ( + + {hunter.display_name} + + ) : ( + {hunter.display_name} + )} +
+ )} +
+ +
+ {messages?.length === 0 &&

{t('messages.emptyThread')}

} + {messages?.map((m) => ( +
+ {/* Text node: message bodies are never interpreted as markup. */} +

{m.body}

+ {whenAgo(m.created_at, t)} +
+ ))} +
+
+ + {canSend ? ( +
{ + e.preventDefault() + void send() + }} + > +