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

@@ -0,0 +1,54 @@
import { NavLink, Link, Outlet, useNavigate } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
// Shared shell for the logged-in player portal: a header with a nav bar
// (Characters / Account) and the page content in an <Outlet />. Matches the
// site's dark theme vocabulary.
const tab = ({ isActive }) => ({
textDecoration: 'none',
fontFamily: 'var(--sans)',
fontSize: '0.9rem',
padding: '8px 4px',
color: isActive ? 'var(--head)' : 'var(--muted)',
borderBottom: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})
export default function PlayerPortalLayout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
async function signOut() {
await logout()
navigate('/account/login', { replace: true })
}
return (
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
<header style={{ borderBottom: '1px solid var(--line)' }}>
<div style={{ maxWidth: 820, margin: '0 auto', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<MoonDot size={12} glow={0.5} />
<div>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.05rem', letterSpacing: '0.04em' }}>UOMysticmoon</div>
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.64rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>Player Portal</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>{user?.username}</span>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}> Site</Link>
<button onClick={signOut} className="pill">Sign out</button>
</div>
</div>
<nav style={{ maxWidth: 820, margin: '0 auto', padding: '0 20px', display: 'flex', gap: 22 }}>
<NavLink to="/player" end style={tab}>Characters</NavLink>
<NavLink to="/account" style={tab}>Account</NavLink>
</nav>
</header>
<div style={{ maxWidth: 820, margin: '0 auto', padding: '28px 20px 60px' }}>
<Outlet />
</div>
</main>
)
}