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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user