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:
226
client/src/routes/public/Shard.jsx
Normal file
226
client/src/routes/public/Shard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user