feat: presence beyond the tab — PWA manifest and the Ghost Log recap
Usability wave Workstream C (#5, #7): - manifest.webmanifest with existing 192/512/180 icons, linked in index.html with theme-color aligned to #07070d; no service worker. - GET /api/seances/recent: last 12 of the seeker's own séances with entity, per-kind event counts (one GROUP BY) and up to 3 spirit echoes (one windowed query) — no per-session N+1. - /log Ghost Log page: cards with GhostGlyph, relative in-fiction timestamps, counts and echo lines; LOG link in the séance topbar; en/es i18n parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -264,6 +264,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