Phase 2: the public UI for the four new boards, following the ChampSpawns live pattern (snapshot via useAsync + merge SSE deltas with useShardFeed). - Players Online widget (components/PlayersOnline.jsx): total + region breakdown rolled up into display buckets (data/regionBuckets.js — the one place to retune the grouping); live via presence.online. Placed on the Shard page, replacing the static players-online stat tile. - Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a "recently joined" strip from guild.join. - Governors (/site/governors): one card per city with a placeholder crest (data/cityCrests.js — swap for real art without touching components), election phase badge + autoPickAt countdown, and an on-demand "past governors" term history (the look-back reads the ledger captured in Phase 1). Clean empty state when City Loyalty isn't enabled. - Houses (/site/houses): searchable registry with decay badges; price labelled "placement value", not a for-sale flag. - API client methods + nav links (Guilds / Governors / Houses). Client build clean (240 modules). Refs .plans/protocol2-integration.md (Phase 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
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'
|
|
import { useAsync } from '../../lib/useAsync.js'
|
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
|
import { describe } from '../../lib/shardEvents.js'
|
|
import { ago } from '../../lib/format.js'
|
|
import { api } from '../../api/client.js'
|
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
|
|
|
// ── 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>
|
|
)
|
|
}
|
|
|
|
export default function Shard() {
|
|
const { loading, error, data } = useAsync(() =>
|
|
Promise.all([
|
|
api.shard.status(),
|
|
api.shard.idoc(),
|
|
api.shard.economy(60),
|
|
api.shard.online(),
|
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
|
)
|
|
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-2" style={{ gap: 14, marginBottom: 24 }}>
|
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
|
</section>
|
|
|
|
{/* Live players-online breakdown (total + region buckets) */}
|
|
<div style={{ marginBottom: 24 }}>
|
|
<PlayersOnline />
|
|
</div>
|
|
|
|
{/* Staff online — linked staff accounts only, with location */}
|
|
<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 }}>
|
|
Staff online
|
|
</div>
|
|
{(!data.online || data.online.length === 0) ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{data.online.map((p) => (
|
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
|
{p.name || p.serial}
|
|
</span>
|
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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 style={{ marginBottom: 24 }}>
|
|
{/* 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>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
|
View all activity →
|
|
</Link>
|
|
<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>
|
|
</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>
|
|
)
|
|
}
|