- Backend: /admin/shard/{link,accounts,roster/:account,vendors/:account} —
staff self-service, reusing the player/shard controller (it keys off
req.user.id, so the same handlers serve any logged-in role). Swagger under
Admin · Account; spec regenerated.
- components/GameAccounts.jsx: the link-prompt + character-roster UI extracted
into one reusable component parametrized by an api scope and a charTo(serial)
route builder.
- PlayerCharacters now renders it (player scope → /player/char/:serial).
- Admin: "My Characters" nav item + /admin/characters (AdminCharacters) and
/admin/characters/:serial (AdminCharacter, in-shell sheet), using the admin
self-service scope. api.admin.shard.* added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
157 lines
6.2 KiB
JavaScript
157 lines
6.2 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { Loading, ErrorState } from './PageState.jsx'
|
||
|
||
// Shared game-account linking + character roster, used by both the player portal
|
||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||
// maps a serial to the route for that character's sheet.
|
||
|
||
function LinkForm({ scope, 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 scope.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>
|
||
)
|
||
}
|
||
|
||
function AccountRoster({ scope, account, charTo }) {
|
||
const [roster, setRoster] = useState(null)
|
||
const [error, setError] = useState('')
|
||
const [unavailable, setUnavailable] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
setError(''); setUnavailable(false)
|
||
try {
|
||
setRoster(await scope.roster(account))
|
||
} catch (err) {
|
||
if (err.status === 503) setUnavailable(true)
|
||
else setError(err.message || 'Could not load this account.')
|
||
}
|
||
}, [scope, 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={charTo(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 GameAccounts({ scope, charTo }) {
|
||
const [accounts, setAccounts] = useState(null)
|
||
const [error, setError] = useState('')
|
||
|
||
const load = useCallback(async () => {
|
||
setError('')
|
||
try {
|
||
setAccounts(await scope.accounts())
|
||
} catch {
|
||
setError('Could not load your game accounts.')
|
||
}
|
||
}, [scope])
|
||
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 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 }}>
|
||
You haven’t linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||
</p>
|
||
<LinkForm scope={scope} onLinked={load} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Linked — characters grouped by account.
|
||
return (
|
||
<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 scope={scope} account={a.account} charTo={charTo} />
|
||
</section>
|
||
))}
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||
<LinkForm scope={scope} onLinked={load} compact />
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|