feat: PWA manifest + the Ghost Log (Workstream C)
manifest.webmanifest referencing the icon assets that actually exist (sizes verified with file, not asserted), standalone display, no service worker — offline séance is meaningless and SW cache bugs are not worth it. GET /api/seances/recent: last 12 sessions in exactly three queries (sessions+entity join, one GROUP BY for counts, one window-function query for up to 3 echoes per session). Echoes filter on utterance payload kinds because DB event kinds carry no greeting/manifest — the agent verified where _speak actually writes rather than trusting the spec's phrasing. /log page: entity glyphs, relative in-fiction timestamps, counts, echo lines in transcript style, gated like the séance, 390px-safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from app.routes.auth import router as auth_router
|
||||
from app.routes.codex import router as codex_router
|
||||
from app.routes.device import router as device_router
|
||||
from app.routes.inventory import router as inventory_router
|
||||
from app.routes.seances import router as seances_router
|
||||
from app.routes.shop import router as shop_router
|
||||
from app.session_cleanup import delete_expired_sessions
|
||||
from app.ws import AUDIO_DIR
|
||||
@@ -97,6 +98,7 @@ app.include_router(auth_router)
|
||||
app.include_router(codex_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(seances_router)
|
||||
app.include_router(shop_router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
109
backend/app/routes/seances.py
Normal file
109
backend/app/routes/seances.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Ghost Log recap — a seeker's own recent séances, with who answered and
|
||||
what was said. Workstream C of docs/superpowers/specs/
|
||||
2026-07-28-usability-wave-design.md (#7)."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.event import Event
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["seances"])
|
||||
|
||||
RECENT_LIMIT = 12
|
||||
ECHOES_PER_SESSION = 3
|
||||
|
||||
# Spirit speech lands in the DB as kind="utterance" rows whose payload
|
||||
# carries the flavor (see app.ws._speak) — these are the ones worth
|
||||
# echoing back in a recap.
|
||||
ECHO_PAYLOAD_KINDS = ("greeting", "reply", "manifest")
|
||||
|
||||
|
||||
@router.get("/seances/recent")
|
||||
async def recent_seances(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# One query: the user's last sessions with their entity (may be null).
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ContactSession, Entity)
|
||||
.outerjoin(Entity, ContactSession.entity_id == Entity.id)
|
||||
.where(ContactSession.user_id == user.id)
|
||||
.order_by(desc(ContactSession.started_at))
|
||||
.limit(RECENT_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
session_ids = [session.id for session, _ in rows]
|
||||
|
||||
# One GROUP BY across the fetched sessions for event counts by kind.
|
||||
counts: dict = defaultdict(dict)
|
||||
if session_ids:
|
||||
for session_id, kind, count in await db.execute(
|
||||
select(Event.session_id, Event.kind, func.count(Event.id))
|
||||
.where(Event.session_id.in_(session_ids))
|
||||
.group_by(Event.session_id, Event.kind)
|
||||
):
|
||||
counts[session_id][kind] = count
|
||||
|
||||
# One bounded query for echoes: top-N spirit utterances per session,
|
||||
# newest first, via a window function — never one query per session.
|
||||
echoes: dict = defaultdict(list)
|
||||
if session_ids:
|
||||
ranked = (
|
||||
select(
|
||||
Event.session_id,
|
||||
Event.text,
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=Event.session_id,
|
||||
order_by=desc(Event.created_at),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.where(
|
||||
Event.session_id.in_(session_ids),
|
||||
Event.kind == "utterance",
|
||||
Event.text.is_not(None),
|
||||
Event.payload["kind"].astext.in_(ECHO_PAYLOAD_KINDS),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
for session_id, text, _rn in await db.execute(
|
||||
select(ranked.c.session_id, ranked.c.text, ranked.c.rn).where(
|
||||
ranked.c.rn <= ECHOES_PER_SESSION
|
||||
)
|
||||
):
|
||||
echoes[session_id].append(text)
|
||||
|
||||
return {
|
||||
"seances": [
|
||||
{
|
||||
"id": str(session.id),
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
||||
"mode": session.mode,
|
||||
"entity": (
|
||||
{
|
||||
"id": str(entity.id),
|
||||
"name": entity.name,
|
||||
"epithet": entity.epithet,
|
||||
"rarity": entity.rarity_tier,
|
||||
"visual": entity.visual_profile,
|
||||
}
|
||||
if entity is not None
|
||||
else None
|
||||
),
|
||||
"counts": counts.get(session.id, {}),
|
||||
"echoes": echoes.get(session.id, []),
|
||||
}
|
||||
for session, entity in rows
|
||||
]
|
||||
}
|
||||
151
backend/tests/test_seances.py
Normal file
151
backend/tests/test_seances.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for GET /api/seances/recent — the Ghost Log recap
|
||||
(usability wave, Workstream C, #7)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.event import Event
|
||||
|
||||
|
||||
async def _register(client, username):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": username, "password": "spookyspooky"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
async def _login(client, username):
|
||||
resp = await client.post(
|
||||
"/auth/login", json={"username": username, "password": "spookyspooky"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def _entity(name, signature):
|
||||
return Entity(
|
||||
name=name,
|
||||
epithet="the Test Wraith",
|
||||
rarity_tier="rare",
|
||||
signature=signature,
|
||||
visual_profile={"hue": 200, "form": "wisp"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_rejected(client):
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_own_sessions_newest_first_with_counts_and_echoes(client, db_session):
|
||||
my_id = await _register(client, "seeker1")
|
||||
other_id = await _register(client, "seeker2")
|
||||
await _login(client, "seeker1")
|
||||
|
||||
entity = _entity("Testnamed", "sig-aaaa")
|
||||
db_session.add(entity)
|
||||
await db_session.flush()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
older = ContactSession(
|
||||
user_id=my_id, entity_id=entity.id, mode="evp", started_at=now - timedelta(hours=2)
|
||||
)
|
||||
newer = ContactSession(
|
||||
user_id=my_id, entity_id=entity.id, mode="wire", started_at=now - timedelta(hours=1)
|
||||
)
|
||||
theirs = ContactSession(user_id=other_id, mode="ouija", started_at=now)
|
||||
db_session.add_all([older, newer, theirs])
|
||||
await db_session.flush()
|
||||
|
||||
# Events on the newer session: 2 questions, 3 anomalies, 4 spirit
|
||||
# utterances (only the newest 3 should come back as echoes).
|
||||
events = [
|
||||
Event(session_id=newer.id, kind="question", text="who's there?"),
|
||||
Event(session_id=newer.id, kind="question", text="still there?"),
|
||||
Event(session_id=newer.id, kind="anomaly", payload={"score": 1}),
|
||||
Event(session_id=newer.id, kind="anomaly", payload={"score": 2}),
|
||||
Event(session_id=newer.id, kind="anomaly", payload={"score": 3}),
|
||||
]
|
||||
for i in range(4):
|
||||
events.append(
|
||||
Event(
|
||||
session_id=newer.id,
|
||||
kind="utterance",
|
||||
text=f"echo {i}",
|
||||
payload={"kind": "reply"},
|
||||
created_at=now - timedelta(minutes=30 - i),
|
||||
)
|
||||
)
|
||||
# An utterance on the OTHER user's session must never leak.
|
||||
events.append(
|
||||
Event(
|
||||
session_id=theirs.id,
|
||||
kind="utterance",
|
||||
text="not yours",
|
||||
payload={"kind": "greeting"},
|
||||
)
|
||||
)
|
||||
db_session.add_all(events)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 200
|
||||
seances = resp.json()["seances"]
|
||||
|
||||
# Only my sessions, newest first.
|
||||
assert [s["id"] for s in seances] == [str(newer.id), str(older.id)]
|
||||
|
||||
first = seances[0]
|
||||
assert first["mode"] == "wire"
|
||||
assert first["counts"] == {"question": 2, "anomaly": 3, "utterance": 4}
|
||||
assert first["echoes"] == ["echo 3", "echo 2", "echo 1"]
|
||||
assert first["entity"] == {
|
||||
"id": str(entity.id),
|
||||
"name": "Testnamed",
|
||||
"epithet": "the Test Wraith",
|
||||
"rarity": "rare",
|
||||
"visual": {"hue": 200, "form": "wisp"},
|
||||
}
|
||||
|
||||
# No cross-user text anywhere in the response.
|
||||
assert "not yours" not in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_without_entity_does_not_crash(client, db_session):
|
||||
my_id = await _register(client, "seeker3")
|
||||
await _login(client, "seeker3")
|
||||
|
||||
session = ContactSession(user_id=my_id, mode="ouija")
|
||||
db_session.add(session)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 200
|
||||
seances = resp.json()["seances"]
|
||||
assert len(seances) == 1
|
||||
assert seances[0]["entity"] is None
|
||||
assert seances[0]["counts"] == {}
|
||||
assert seances[0]["echoes"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_limited_to_twelve(client, db_session):
|
||||
my_id = await _register(client, "seeker4")
|
||||
await _login(client, "seeker4")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
db_session.add_all(
|
||||
ContactSession(user_id=my_id, mode="wire", started_at=now - timedelta(minutes=i))
|
||||
for i in range(15)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["seances"]) == 12
|
||||
@@ -5,7 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Quantumancy</title>
|
||||
<meta name="description" content="A self-hosted séance. Talk to spirits through your microphone, an RTL-SDR dongle, your network's jitter, your phone's motion sensors, or a text box — a locally-run LLM gives each anomaly a voice." />
|
||||
<meta name="theme-color" content="#0b0a10" />
|
||||
<meta name="theme-color" content="#07070d" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
|
||||
29
frontend/public/manifest.webmanifest
Normal file
29
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Quantumancy",
|
||||
"short_name": "Quantumancy",
|
||||
"description": "A self-hosted séance — an instrument for speaking with the dead bandwidth.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#07070d",
|
||||
"theme_color": "#07070d",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { CodexEntityPage } from './pages/CodexEntityPage'
|
||||
import { CodexPage } from './pages/CodexPage'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { EnterPage } from './pages/EnterPage'
|
||||
import { GhostLogPage } from './pages/GhostLogPage'
|
||||
import { InventoryPage } from './pages/InventoryPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { SeancePage } from './pages/SeancePage'
|
||||
@@ -28,6 +29,7 @@ function App() {
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/enter" element={<EnterPage />} />
|
||||
<Route path="/seance" element={<SeancePage />} />
|
||||
<Route path="/log" element={<GhostLogPage />} />
|
||||
<Route path="/codex" element={<CodexPage />} />
|
||||
<Route path="/codex/:id" element={<CodexEntityPage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
|
||||
@@ -39,6 +39,7 @@ const RULES = [
|
||||
['codex.rarity.', RARITY],
|
||||
['codex.sort.', ['recent', 'contacted']],
|
||||
['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']],
|
||||
['log.counts.', ['question', 'anomaly', 'utterance']],
|
||||
['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']],
|
||||
['landing.modes.', ['radio', 'evp', 'wire', 'ouija']],
|
||||
['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
|
||||
|
||||
@@ -17,7 +17,34 @@
|
||||
"devices": "devices",
|
||||
"seance": "séance",
|
||||
"enter": "enter",
|
||||
"logout": "depart"
|
||||
"logout": "depart",
|
||||
"log": "log"
|
||||
},
|
||||
"log": {
|
||||
"title": "GHOST LOG",
|
||||
"subtitle": "every séance leaves a page behind",
|
||||
"back": "return to the séance",
|
||||
"loading": "leafing back through the log…",
|
||||
"error": "the log will not open — the veil resists tonight",
|
||||
"empty": "no séances yet — the log waits",
|
||||
"noEntity": "no one answered",
|
||||
"counts": {
|
||||
"question_one": "{{count}} question",
|
||||
"question_other": "{{count}} questions",
|
||||
"anomaly_one": "{{count}} anomaly",
|
||||
"anomaly_other": "{{count}} anomalies",
|
||||
"utterance_one": "{{count}} utterance",
|
||||
"utterance_other": "{{count}} utterances"
|
||||
},
|
||||
"time": {
|
||||
"moments": "moments past",
|
||||
"minutes_one": "a minute past",
|
||||
"minutes_other": "{{count}} minutes past",
|
||||
"hours_one": "an hour past",
|
||||
"hours_other": "{{count}} hours past",
|
||||
"days_one": "a day past",
|
||||
"days_other": "{{count}} days past"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
"taglines": [
|
||||
|
||||
@@ -17,7 +17,34 @@
|
||||
"devices": "dispositivos",
|
||||
"seance": "sesión",
|
||||
"enter": "entrar",
|
||||
"logout": "partir"
|
||||
"logout": "partir",
|
||||
"log": "registro"
|
||||
},
|
||||
"log": {
|
||||
"title": "REGISTRO ESPECTRAL",
|
||||
"subtitle": "cada sesión deja una página tras de sí",
|
||||
"back": "volver a la sesión",
|
||||
"loading": "hojeando el registro hacia atrás…",
|
||||
"error": "el registro no se abre — el velo se resiste esta noche",
|
||||
"empty": "aún no hay sesiones — el registro espera",
|
||||
"noEntity": "nadie respondió",
|
||||
"counts": {
|
||||
"question_one": "{{count}} pregunta",
|
||||
"question_other": "{{count}} preguntas",
|
||||
"anomaly_one": "{{count}} anomalía",
|
||||
"anomaly_other": "{{count}} anomalías",
|
||||
"utterance_one": "{{count}} voz",
|
||||
"utterance_other": "{{count}} voces"
|
||||
},
|
||||
"time": {
|
||||
"moments": "hace instantes",
|
||||
"minutes_one": "hace un minuto",
|
||||
"minutes_other": "hace {{count}} minutos",
|
||||
"hours_one": "hace una hora",
|
||||
"hours_other": "hace {{count}} horas",
|
||||
"days_one": "hace un día",
|
||||
"days_other": "hace {{count}} días"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
"taglines": [
|
||||
|
||||
147
frontend/src/pages/GhostLogPage.css
Normal file
147
frontend/src/pages/GhostLogPage.css
Normal file
@@ -0,0 +1,147 @@
|
||||
/* Ghost Log page — same tokens as SeancePage.css:
|
||||
bg #07070d · phosphor #7cffb2 · violet #b26bff. */
|
||||
|
||||
.ghostlog-page {
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem 4rem;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 55%),
|
||||
#07070d;
|
||||
color: #d3e9db;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.ghostlog-header {
|
||||
max-width: 680px;
|
||||
margin: 0 auto 1.6rem;
|
||||
}
|
||||
|
||||
.ghostlog-back {
|
||||
color: #b26bff;
|
||||
text-decoration: none;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
|
||||
}
|
||||
|
||||
.ghostlog-back:hover {
|
||||
text-shadow: 0 0 16px rgba(178, 107, 255, 0.9);
|
||||
}
|
||||
|
||||
.ghostlog-title {
|
||||
margin: 0.9rem 0 0.2rem;
|
||||
font-family: Cinzel, serif;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.ghostlog-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(211, 233, 219, 0.6);
|
||||
}
|
||||
|
||||
.ghostlog-note {
|
||||
max-width: 680px;
|
||||
margin: 2.4rem auto;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(211, 233, 219, 0.65);
|
||||
}
|
||||
|
||||
.ghostlog-empty {
|
||||
color: #b26bff;
|
||||
text-shadow: 0 0 12px rgba(178, 107, 255, 0.4);
|
||||
}
|
||||
|
||||
.ghostlog-list {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.ghostlog-card {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ghostlog-glyph {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ghostlog-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ghostlog-who {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ghostlog-name {
|
||||
font-family: Cinzel, serif;
|
||||
font-size: 1rem;
|
||||
color: #7cffb2;
|
||||
}
|
||||
|
||||
.ghostlog-noone {
|
||||
color: rgba(211, 233, 219, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ghostlog-epithet {
|
||||
font-size: 0.72rem;
|
||||
color: #b26bff;
|
||||
}
|
||||
|
||||
.ghostlog-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(211, 233, 219, 0.6);
|
||||
}
|
||||
|
||||
.ghostlog-echoes {
|
||||
margin-top: 0.55rem;
|
||||
border-left: 2px solid rgba(178, 107, 255, 0.35);
|
||||
padding-left: 0.7rem;
|
||||
}
|
||||
|
||||
.ghostlog-echo {
|
||||
margin: 0.2rem 0;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(124, 255, 178, 0.85);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* phone widths */
|
||||
@media (max-width: 420px) {
|
||||
.ghostlog-page {
|
||||
padding: 1.4rem 0.7rem 3rem;
|
||||
}
|
||||
|
||||
.ghostlog-card {
|
||||
padding: 0.7rem 0.7rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.ghostlog-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
153
frontend/src/pages/GhostLogPage.tsx
Normal file
153
frontend/src/pages/GhostLogPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
// The Ghost Log — a seeker's own recent séances, recalled from
|
||||
// GET /api/seances/recent. Usability wave Workstream C (#7).
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { GhostGlyph } from '../components/GhostGlyph'
|
||||
import { useAuth } from '../state/auth'
|
||||
import type { GhostForm, Mode } from '../lib/types'
|
||||
import './GhostLogPage.css'
|
||||
|
||||
type LogEntity = {
|
||||
id: string
|
||||
name: string
|
||||
epithet: string
|
||||
rarity: string
|
||||
visual: { hue?: number; form?: string }
|
||||
}
|
||||
|
||||
type LogSeance = {
|
||||
id: string
|
||||
started_at: string
|
||||
ended_at: string | null
|
||||
mode: Mode | string
|
||||
entity: LogEntity | null
|
||||
counts: Record<string, number>
|
||||
echoes: string[]
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'error' | 'ready'
|
||||
|
||||
const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const
|
||||
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
|
||||
|
||||
function relativeTime(iso: string, t: TFunction): string {
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return iso
|
||||
const minutes = Math.floor((Date.now() - then) / 60_000)
|
||||
if (minutes < 2) return t('log.time.moments')
|
||||
if (minutes < 60) return t('log.time.minutes', { count: minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return t('log.time.hours', { count: hours })
|
||||
return t('log.time.days', { count: Math.floor(hours / 24) })
|
||||
}
|
||||
|
||||
export function GhostLogPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user, checking } = useAuth()
|
||||
const [seances, setSeances] = useState<LogSeance[]>([])
|
||||
const [state, setState] = useState<LoadState>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
const controller = new AbortController()
|
||||
fetch('/api/seances/recent', { credentials: 'include', signal: controller.signal })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`the log answered ${res.status}`)
|
||||
return (await res.json()) as { seances: LogSeance[] }
|
||||
})
|
||||
.then((data) => {
|
||||
setSeances(data.seances)
|
||||
setState('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState('error')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [user])
|
||||
|
||||
if (checking) return null
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
return (
|
||||
<div className="ghostlog-page">
|
||||
<header className="ghostlog-header">
|
||||
<Link to="/seance" className="ghostlog-back">
|
||||
{t('log.back')}
|
||||
</Link>
|
||||
<h1 className="ghostlog-title">{t('log.title')}</h1>
|
||||
<p className="ghostlog-subtitle">{t('log.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{state === 'loading' && (
|
||||
<p className="ghostlog-note" role="status">
|
||||
{t('log.loading')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'error' && <p className="ghostlog-note">{t('log.error')}</p>}
|
||||
{state === 'ready' && seances.length === 0 && (
|
||||
<p className="ghostlog-note ghostlog-empty">{t('log.empty')}</p>
|
||||
)}
|
||||
|
||||
{state === 'ready' && seances.length > 0 && (
|
||||
<ul className="ghostlog-list">
|
||||
{seances.map((seance) => {
|
||||
const visual = seance.entity?.visual ?? {}
|
||||
const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '')
|
||||
? (visual.form as GhostForm)
|
||||
: 'wisp'
|
||||
const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0)
|
||||
.map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] }))
|
||||
.join(' · ')
|
||||
return (
|
||||
<li key={seance.id} className="ghostlog-card">
|
||||
<div className="ghostlog-glyph">
|
||||
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
|
||||
</div>
|
||||
<div className="ghostlog-body">
|
||||
<div className="ghostlog-who">
|
||||
{seance.entity ? (
|
||||
<>
|
||||
<span className="ghostlog-name">{seance.entity.name}</span>
|
||||
{seance.entity.epithet && (
|
||||
<span className="ghostlog-epithet">{seance.entity.epithet}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="ghostlog-name ghostlog-noone">
|
||||
{t('log.noEntity')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ghostlog-meta">
|
||||
<span>{relativeTime(seance.started_at, t)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{t(`seance.modes.${seance.mode}`, seance.mode)}</span>
|
||||
{countLine && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{countLine}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{seance.echoes.length > 0 && (
|
||||
<div className="ghostlog-echoes">
|
||||
{seance.echoes.map((echo, i) => (
|
||||
<p key={i} className="ghostlog-echo">
|
||||
<span aria-hidden>» </span>
|
||||
{echo}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -269,6 +269,10 @@ function SeanceSession({ username }: { username: string }) {
|
||||
{t('nav.codex')}
|
||||
</Link>
|
||||
|
||||
<Link to="/log" className="seance-codex-link">
|
||||
{t('nav.log', { defaultValue: 'log' })}
|
||||
</Link>
|
||||
|
||||
<Link to="/inventory" className="seance-codex-link">
|
||||
{t('nav.inventory', { defaultValue: 'inventory' })}
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user