Add public Shard page + player Game Accounts UI (phase 4)

Frontend for the uo-link integration, matching the existing site styling.

- api/client.js: api.shard.* (status/feed/economy/idoc/char), the
  shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/
  vendors).
- lib/useShardFeed.js: EventSource hook over /public/shard/stream with a
  rolling buffer and a connected flag (browser never touches the sidecar WS).
- routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply
  / link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses"
  lists, and a live event ticker — built from the shared panel/grid/format
  vocabulary. Registered at /site/shard under the maintenance gate and linked
  from the site header.
- routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link
  code to link an account, then expand it to see characters and player vendors
  on demand (503 shows a retry banner).

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:17:45 -05:00
parent 064f02c4b6
commit 1c9a9d26e1
6 changed files with 480 additions and 0 deletions

View File

@@ -297,6 +297,175 @@ 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 (
@@ -363,6 +532,7 @@ export default function PlayerAccount() {
{account.email ? ` · ${account.email}` : ''}
</p>
</div>
<GameAccounts />
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />

View File

@@ -0,0 +1,226 @@
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
// ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) {
if (!series || series.length < 2) return null
const w = 320
const h = 56
const golds = series.map((s) => Number(s.gold) || 0)
const min = Math.min(...golds)
const max = Math.max(...golds)
const span = max - min || 1
const pts = series
.map((s, i) => {
const x = (i / (series.length - 1)) * w
const y = h - ((Number(s.gold) || 0) - min) / span * h
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
function Stat({ 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.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
{label}
</div>
</div>
)
}
// A one-line human description of a feed event.
function describe(ev) {
const p = ev.payload || ev
switch (ev.kind) {
case 'vendor.sale':
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${Number(p.price || 0).toLocaleString()}gp`
case 'player.death':
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
case 'player.murdered':
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
case 'mob.killed':
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
case 'house.decay':
return `${p.name || 'A house'} is now ${p.to || p.stage}`
case 'quest.complete':
return `${nameOf(p.who)} completed “${p.quest}`
case 'skill.gain':
return `${nameOf(p.who)} gained ${p.skill}`
case 'mob.login':
return `${nameOf(p.who)} entered the world`
case 'mob.logout':
return `${nameOf(p.who)} left the world`
default:
return ev.kind
}
}
function nameOf(who) {
if (!who) return 'Someone'
if (typeof who === 'string') return who
return who.name || who.acct || 'Someone'
}
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 }),
),
)
const { events, connected } = useShardFeed({ max: 30 })
const status = data?.status
const online = status?.pluginConnected
const gold = status?.economy?.gold
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard" />
{loading && <Loading />}
{error && <ErrorState message="Could not load shard data right now." />}
{!loading && !error && data && (
<>
{/* Connection banner */}
<section
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '24px 26px',
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
borderRadius: 10,
background: online
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
marginBottom: 24,
}}
>
<span
style={{
flex: 'none',
width: 12,
height: 12,
borderRadius: '50%',
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
}}
/>
<div>
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
{online ? 'The shard is online' : 'The shard is offline'}
</strong>
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
{online
? 'The gate to Britannia stands open.'
: status?.enabled
? 'The link to the game world is down — checking back automatically.'
: 'Live shard data is not configured yet.'}
</span>
</div>
</section>
{/* Stat tiles */}
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
Gold supply over time
</div>
<Sparkline series={data.economy} />
</section>
)}
<div className="grid-2" style={{ gap: 18, marginBottom: 24 }}>
{/* Recent vendor sales */}
<FeedList
title="Recent vendor sales"
empty="No sales recorded yet."
items={data.sales.map((s) => ({ id: s.id, text: describe(s), when: s.t }))}
/>
{/* Latest IDOC */}
<FeedList
title="Houses in danger (IDOC)"
empty="No houses are collapsing right now."
items={data.idoc.map((h) => ({
id: h.serial,
text: `${h.name || 'A house'}${h.region ? `${h.region}` : ''}`,
when: h.updatedAt,
}))}
/>
</div>
{/* Live ticker */}
<section className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
Live feed
</div>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
Waiting for something to happen in the world
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{events.map((ev) => (
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
</PublicLayout>
)
}
function FeedList({ title, items, empty }) {
return (
<section className="panel" style={{ padding: 20 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{title}
</div>
{items.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((it) => (
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
</li>
))}
</ul>
)}
</section>
)
}