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

@@ -17,6 +17,7 @@ import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardChar from './routes/public/ShardChar.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -44,6 +45,9 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
export default function App() {
@@ -69,6 +73,7 @@ export default function App() {
<Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes
@@ -123,13 +128,16 @@ export default function App() {
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route
path="/account"
element={
<RequirePlayer>
<PlayerAccount />
<PlayerPortalLayout />
</RequirePlayer>
}
/>
>
<Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -92,6 +92,7 @@ export const api = {
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
},
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
},

View File

@@ -0,0 +1,141 @@
// Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
export default function CharacterSheet({ char }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
// Skills the character actually has, best first.
const skills = (char.skills || [])
.filter((s) => (s.value || s.base || 0) > 0)
.sort((a, b) => (b.value || 0) - (a.value || 0))
const equipment = char.equipment || []
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
</div>
)
}

View File

@@ -1,6 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
@@ -297,175 +295,6 @@ function LinkedAccounts() {
)
}
// ── Game accounts (uo-link) ────────────────────────────────────────────────
function GameAccounts() {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [linkError, setLinkError] = useState('')
const [selected, setSelected] = useState(null) // account being inspected
const load = useCallback(async () => {
try {
setAccounts(await api.player.shard.accounts())
} catch {
setError('Could not load your linked game accounts.')
}
}, [])
useEffect(() => { load() }, [load])
async function link(e) {
e.preventDefault()
setMsg('')
setLinkError('')
if (!code.trim()) return
setBusy(true)
try {
const { account } = await api.player.shard.link(code.trim())
setMsg(`Linked ${account}.`)
setCode('')
await load()
} catch (err) {
setLinkError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!accounts) return null
return (
<Section title="Game accounts">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Link your in-game account to see your characters and player vendors here. In game, type{' '}
<code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
</p>
<form onSubmit={link} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', margin: '14px 0 4px' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Link code</span>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="input"
autoComplete="off"
placeholder="AB12CD"
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
/>
</label>
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Linking…' : 'Link account'}
</button>
</form>
<Note msg={msg} error={linkError} />
{accounts.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '18px 0 0' }}>
{accounts.map((a) => (
<div key={a.account} style={{ border: '1px solid var(--line)', borderRadius: 8, padding: '12px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{a.account}</div>
<div className="sans dim" style={{ fontSize: '0.76rem' }}>Linked {new Date(a.linkedAt).toLocaleDateString()}</div>
</div>
<button
className="pill"
onClick={() => setSelected(selected === a.account ? null : a.account)}
>
{selected === a.account ? 'Hide' : 'View'}
</button>
</div>
{selected === a.account && <AccountDetail account={a.account} />}
</div>
))}
</div>
)}
{accounts.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.86rem', marginBottom: 0 }}>No game accounts linked yet.</p>
)}
</Section>
)
}
// Roster + vendors for one linked account, loaded on demand. Handles the shard
// restart (503) path with a retry-able banner.
function AccountDetail({ account }) {
const [roster, setRoster] = useState(null)
const [vendors, setVendors] = useState(null)
const [error, setError] = useState('')
const [unavailable, setUnavailable] = useState(false)
const load = useCallback(async () => {
setError('')
setUnavailable(false)
try {
const [r, v] = await Promise.all([
api.player.shard.roster(account),
api.player.shard.vendors(account).catch(() => null),
])
setRoster(r)
setVendors(v)
} catch (err) {
if (err.status === 503) setUnavailable(true)
else setError(err.message || 'Could not load this account.')
}
}, [account])
useEffect(() => { load() }, [load])
if (unavailable) {
return (
<div style={{ marginTop: 12 }}>
<p className="sans" style={{ margin: 0, color: '#e0b070', fontSize: '0.85rem' }}>
The game server is restarting try again shortly.
</p>
<button className="pill" style={{ marginTop: 8 }} onClick={load}>Retry</button>
</div>
)
}
if (error) return <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
if (!roster) return <p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.82rem' }}>Loading</p>
const chars = roster.chars || []
const shops = (vendors && vendors.vendors) || []
return (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div className="field-label" style={{ marginBottom: 6 }}>Characters</div>
{chars.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>No characters found.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{chars.map((c) => (
<div key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.86rem', color: 'var(--ink)' }}>
<span>{c.name}</span>
<span className="dim" style={{ fontSize: '0.76rem' }}>{c.online ? 'Online' : 'Offline'}</span>
</div>
))}
</div>
)}
</div>
{shops.length > 0 && (
<div>
<div className="field-label" style={{ marginBottom: 6 }}>Player vendors</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{shops.map((s) => (
<div key={s.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.86rem', color: 'var(--ink)' }}>
<span>{s.shopName || 'Vendor'}</span>
<span className="dim" style={{ fontSize: '0.76rem' }}>{Number(s.holdGold || 0).toLocaleString()}gp</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
// ── Shared bits ────────────────────────────────────────────────────────────
function Section({ title, children }) {
return (
@@ -482,7 +311,7 @@ function Note({ msg, error }) {
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerAccount() {
const { logout, refresh } = useAuth()
const { refresh } = useAuth()
const [account, setAccount] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -505,41 +334,22 @@ export default function PlayerAccount() {
}, [load, refresh])
return (
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<MoonDot size={12} glow={0.5} />
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
My Account
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
Site
</Link>
<button onClick={logout} className="pill">Sign out</button>
</div>
</header>
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
{loading && <Loading />}
{error && <ErrorState message={error} />}
{!loading && !error && account && (
<>
<div style={{ paddingTop: 24 }}>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
{account.email ? ` · ${account.email}` : ''}
</p>
</div>
<GameAccounts />
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />
<LinkedAccounts />
</>
)}
</div>
</main>
<div>
<h1 className="display" style={{ margin: '0 0 4px', fontSize: '1.6rem', color: 'var(--head)' }}>Account</h1>
{loading && <Loading />}
{error && <ErrorState message={error} />}
{!loading && !error && account && (
<>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
{account.email ? ` · ${account.email}` : ''}
</p>
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />
<LinkedAccounts />
</>
)}
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { useParams, Link } from 'react-router-dom'
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'
// A player's character sheet inside the portal. Character data is public MMO
// data, so it uses the same cached public endpoint the site does.
export default function PlayerCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
const restarting = error && error.status === 503
return (
<div>
<p style={{ margin: '0 0 18px' }}>
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{error && !restarting && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
// One-time-code linking form (shared by the empty state and "add another").
function LinkForm({ onLinked, compact }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function submit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!code.trim()) return
setBusy(true)
try {
const { account } = await api.player.shard.link(code.trim())
setMsg(`Linked ${account}.`)
setCode('')
await onLinked()
} catch (err) {
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
<label style={{ display: 'block' }}>
{!compact && <span className="field-label">Link code</span>}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="input"
autoComplete="off"
placeholder="AB12CD"
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
/>
</label>
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Linking…' : 'Link account'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</form>
)
}
// Roster of one linked account → character cards linking to the sheet.
function AccountRoster({ account }) {
const [roster, setRoster] = useState(null)
const [error, setError] = useState('')
const [unavailable, setUnavailable] = useState(false)
const load = useCallback(async () => {
setError(''); setUnavailable(false)
try {
setRoster(await api.player.shard.roster(account))
} catch (err) {
if (err.status === 503) setUnavailable(true)
else setError(err.message || 'Could not load this account.')
}
}, [account])
useEffect(() => { load() }, [load])
if (unavailable) {
return (
<div>
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting try again shortly.</p>
<button className="pill" onClick={load}>Retry</button>
</div>
)
}
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading</p>
const chars = roster.chars || []
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
return (
<div className="grid-2" style={{ gap: 12 }}>
{chars.map((c) => (
<Link
key={c.serial}
to={`/player/char/${c.serial}`}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
>
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
{(c.name || '?').charAt(0)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
</div>
<span className="sans dim" style={{ fontSize: '1.1rem' }}></span>
</Link>
))}
</div>
)
}
export default function PlayerCharacters() {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
const load = useCallback(async () => {
setError('')
try {
setAccounts(await api.player.shard.accounts())
} catch {
setError('Could not load your game accounts.')
}
}, [])
useEffect(() => { load() }, [load])
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// Not linked yet — prompt to link.
if (accounts.length === 0) {
return (
<div>
<h1 className="display" style={{ margin: '0 0 6px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
<p className="sans" style={{ margin: '0 0 22px', color: 'var(--muted)', fontSize: '0.92rem', lineHeight: 1.6 }}>
You havent linked a game account yet. Link one to see your characters, stats, skills and vendors here.
</p>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
</p>
<LinkForm onLinked={load} />
</div>
</div>
)
}
// Linked — show characters grouped by account.
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
<h1 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{a.account}
</div>
<AccountRoster account={a.account} />
</section>
))}
</div>
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm onLinked={load} compact />
</section>
</div>
)
}

View File

@@ -20,7 +20,7 @@ export default function PlayerLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/account'
const dest = location.state?.from?.pathname || '/player'
// A staff member who signs in here belongs in the admin shell, not the portal.
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)

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>
)
}

View File

@@ -21,7 +21,7 @@ export default function PlayerRegister() {
const [providers, setProviders] = useState([])
useEffect(() => {
if (user && user.role === 'player') navigate('/account', { replace: true })
if (user && user.role === 'player') navigate('/player', { replace: true })
}, [user, navigate])
useEffect(() => {
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
setBusy(true)
try {
await register(username.trim(), password, { email: email.trim() || undefined, company })
navigate('/account', { replace: true })
navigate('/player', { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else if (err.status === 403) setError('Registration is not open right now.')

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>
)
}

View File

@@ -162,6 +162,13 @@ publicRouter.get(
validate,
shard.getEconomy,
)
publicRouter.get(
'/shard/online',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Players online now (name + serial + map only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
shard.getOnline,
)
publicRouter.get(
'/shard/idoc',
// #swagger.tags = ['Public · Shard']

View File

@@ -69,6 +69,19 @@ async function getEconomy(req, res) {
}
}
// GET /public/shard/online — who is online now (redacted: name + serial + map,
// no coordinates, vitals or account). Feeds the public "online now" list, which
// links to the public character sheet.
async function getOnline(req, res) {
try {
const rows = await shardState.listOnline()
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
} catch (err) {
log.error('shard.getOnline', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
async function getIdoc(req, res) {
try {
@@ -118,4 +131,4 @@ function stream(req, res) {
broadcast.subscribe(req, res, 'public')
}
module.exports = { getStatus, getFeed, getEconomy, getIdoc, getChar, stream }
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream }

View File

@@ -1410,6 +1410,33 @@
}
}
},
"/api/v1/public/shard/online": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "Players online now (name + serial + map only)",
"description": "",
"responses": {
"200": {
"description": "Online players",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ShardOnlinePlayer"
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/idoc": {
"get": {
"tags": [
@@ -10307,6 +10334,67 @@
}
}
},
"ShardOnlinePlayer": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "A player online now (redacted for the public list)."
},
"properties": {
"type": "object",
"properties": {
"serial": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "0x24C"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Darrow"
}
}
},
"map": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Trammel"
}
}
}
}
}
}
},
"ShardHouse": {
"type": "object",
"properties": {

View File

@@ -544,6 +544,15 @@ const doc = {
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
},
},
ShardOnlinePlayer: {
type: 'object',
description: 'A player online now (redacted for the public list).',
properties: {
serial: { type: 'string', example: '0x24C' },
name: { type: 'string', example: 'Darrow' },
map: { type: 'string', nullable: true, example: 'Trammel' },
},
},
ShardHouse: {
type: 'object',
description: 'A house at its current decay stage.',