Add player portal + character-sheet front end (phase 4 follow-up)

Turns the raw shard endpoints into proper, navigable pages in the site's visual
language.

- components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars,
  resistances, skills (with bars), and equipment — styled with the shared
  panel/grid vocabulary.
- Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs +
  sign-out) wraps /player and /account. /player (PlayerCharacters) tells the
  logged-in player if they haven't linked a game account (with the [link code
  prompt) or, once linked, shows their characters grouped by account; each
  character opens its sheet at /player/char/:serial. Account security moved into
  the same shell (the buried "Game accounts" block was removed from it).
  Login/register now land on /player.
- Public: GET /public/shard/online (redacted name+serial+map) drives an
  "Online now" list on /site/shard that links to public character sheets at
  /site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated.
- api.shard.online added.

Verified live against the running shard: Darrow's full sheet (STR 120, 58
skills, 3 equipment) renders through the browser-facing proxy; the online list
returns the live roster; player routes 401 without a session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
2026-07-11 02:51:50 -05:00
parent e7bc316863
commit fe6f93481b
15 changed files with 609 additions and 218 deletions

View File

@@ -1,3 +1,4 @@
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
@@ -75,9 +76,13 @@ function nameOf(who) {
export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([api.shard.status(), api.shard.feed({ kind: 'vendor.sale', limit: 8 }), api.shard.idoc(), api.shard.economy(60)]).then(
([status, sales, idoc, economy]) => ({ status, sales, idoc, economy }),
),
Promise.all([
api.shard.status(),
api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
api.shard.idoc(),
api.shard.economy(60),
api.shard.online(),
]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
@@ -141,6 +146,31 @@ export default function Shard() {
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Online now */}
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
Online now
</div>
{(!data.online || data.online.length === 0) ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No one is online right now.</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{data.online.map((p) => (
<Link
key={p.serial}
to={`/site/shard/char/${p.serial}`}
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px', border: '1px solid var(--line)', borderRadius: 999, color: 'var(--ink)', textDecoration: 'none' }}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
{p.name || p.serial}
{p.map && <span className="dim" style={{ fontSize: '0.76rem' }}>· {p.map}</span>}
</Link>
))}
</div>
)}
</section>
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>

View File

@@ -0,0 +1,37 @@
import { useParams, Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// Public character viewer: /site/shard/char/:serial. Renders the live sheet from
// the sidecar (cached server-side). A 503 means the shard is restarting.
export default function ShardChar() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
const restarting = error && error.status === 503
const notFound = error && error.status === 404
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Character" title={data?.name || 'Character'} />
<p style={{ marginTop: -8, marginBottom: 20 }}>
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to shard
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{notFound && <ErrorState message="No character with that serial." />}
{error && !restarting && !notFound && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
</PublicLayout>
)
}