Bring player portal in line with Admin + stat-tile My Characters

Implements the "Frontend Theme Redo" design (decision 1a): the logged-in
player portal now uses the same sidebar shell as Admin, and Admin's own
My Characters view gets the same stat-tile treatment.

- PlayerPortalLayout: replace the light 820px top-tab header with the
  Admin sidebar shell (icon nav, sticky content header with page title,
  signed-in footer with sign out). Reuses .admin-grid so the two
  logged-in experiences read as one app.
- Drop the now-redundant inner <h1> from PlayerCharacters/PlayerAccount;
  the title lives in the sticky header.
- CharacterStats: new stat-tile row (Characters / Online now / Linked
  account) that tolerates a restarting shard and hides until an account
  is linked.
- AdminCharacters: render CharacterStats above the roster instead of the
  bare intro paragraph, matching the Player Portal Characters page.

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 10:58:03 -05:00
parent 6c310629c7
commit bf9edde5b7
5 changed files with 215 additions and 38 deletions

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
// A small stat-tile row for a "My Characters" page: total characters, how many
// are online right now, and how many game accounts are linked. `scope` is the
// shard api object (admin or player self-service). Renders nothing until an
// account is linked, so the empty/link-prompt state below it stands alone.
//
// It fetches the same rosters GameAccounts loads; for a personal page that's at
// most a couple of extra live round-trips, and keeps this presentational bit
// decoupled from GameAccounts' per-account roster loading.
function Tile({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
{label}
</div>
</div>
)
}
export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const accounts = await scope.accounts()
const linked = accounts.length
if (linked === 0) {
if (!cancelled) setStats({ linked: 0 })
return
}
// Roster is a live round-trip and can be unavailable (503); tolerate a
// partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
let chars = 0
let online = 0
let complete = true
for (const r of rosters) {
if (r.status === 'fulfilled') {
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
} else {
complete = false
}
}
if (!cancelled) setStats({ linked, chars, online, complete })
} catch {
if (!cancelled) setStats({ error: true })
}
})()
return () => { cancelled = true }
}, [scope])
// Hidden until we know an account is linked (or while first loading).
if (!stats || stats.error || stats.linked === 0) return null
// Counts depend on live rosters; show a dash if none came back.
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
return (
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
<Tile value={count(stats.chars)} label="Characters" />
<Tile value={count(stats.online)} label="Online now" />
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
</section>
)
}