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>
85 lines
2.9 KiB
JavaScript
85 lines
2.9 KiB
JavaScript
import { useMemo } from 'react'
|
|
import { useAsync } from '../lib/useAsync.js'
|
|
import { useShardFeed } from '../lib/useShardFeed.js'
|
|
import { bucketize } from '../data/regionBuckets.js'
|
|
import { api } from '../api/client.js'
|
|
|
|
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
|
// then keeps the total + region breakdown current from the presence.online SSE
|
|
// kind. The raw byRegion map is rolled up into display buckets (see
|
|
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
|
const PRESENCE_KINDS = new Set(['presence.online'])
|
|
|
|
export default function PlayersOnline() {
|
|
const { loading, error, data } = useAsync(() => api.shard.presence())
|
|
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
|
|
|
// The freshest snapshot wins: the newest buffered presence.online event, else
|
|
// the initial fetch.
|
|
const snapshot = events[0] || data
|
|
|
|
const { total, rows } = useMemo(() => {
|
|
const count = Number(snapshot?.count) || 0
|
|
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
|
return { total: count, rows: bucketRows }
|
|
}, [snapshot])
|
|
|
|
return (
|
|
<section className="panel" style={{ padding: 20 }}>
|
|
<div
|
|
className="sans"
|
|
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
|
>
|
|
<span
|
|
style={{
|
|
color: 'var(--accent)',
|
|
fontSize: '0.7rem',
|
|
letterSpacing: '0.12em',
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
Players online
|
|
</span>
|
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
|
{loading ? '—' : total}
|
|
</span>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
|
Population is unavailable right now.
|
|
</p>
|
|
)}
|
|
|
|
{!loading && !error && (
|
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{rows.length === 0 ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
|
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
|
</p>
|
|
) : (
|
|
rows.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
className="sans"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
fontSize: '0.9rem',
|
|
color: 'var(--ink)',
|
|
}}
|
|
>
|
|
<span>{r.label}</span>
|
|
{/* tabular figures keep the right-aligned counts in a clean column */}
|
|
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|