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>
170 lines
6.9 KiB
JavaScript
170 lines
6.9 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
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 { api } from '../../api/client.js'
|
|
|
|
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
|
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
|
// joined" strip on top of the board.
|
|
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
|
|
|
function Leader({ leader }) {
|
|
if (!leader || !leader.name) return <span className="dim">—</span>
|
|
return <span>{leader.name}</span>
|
|
}
|
|
|
|
function GuildRow({ g }) {
|
|
return (
|
|
<div
|
|
className="panel"
|
|
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
|
>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
|
{g.abbr && (
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
flex: 'none',
|
|
fontSize: '0.72rem',
|
|
letterSpacing: '0.06em',
|
|
color: 'var(--accent)',
|
|
border: '1px solid rgba(201,162,75,0.4)',
|
|
borderRadius: 5,
|
|
padding: '1px 6px',
|
|
}}
|
|
>
|
|
{g.abbr}
|
|
</span>
|
|
)}
|
|
<strong
|
|
className="display"
|
|
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
>
|
|
{g.name || 'A guild'}
|
|
</strong>
|
|
</div>
|
|
{g.alliance && (
|
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
|
{g.alliance}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
|
<div>
|
|
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
|
<span className="dim"> / {g.members ?? 0}</span>
|
|
</div>
|
|
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
|
<Leader leader={g.leader} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Guilds() {
|
|
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
|
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
|
const [q, setQ] = useState('')
|
|
|
|
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
|
const board = useMemo(() => {
|
|
const map = new Map()
|
|
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
const ev = events[i]
|
|
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
|
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
|
}
|
|
return [...map.values()]
|
|
}, [data, events])
|
|
|
|
// Recent joins strip (newest first, deduped, capped).
|
|
const joins = useMemo(
|
|
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
|
[events],
|
|
)
|
|
|
|
const filtered = useMemo(() => {
|
|
const needle = q.trim().toLowerCase()
|
|
const rows = needle
|
|
? board.filter((g) =>
|
|
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
|
)
|
|
: board
|
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
|
}, [board, q])
|
|
|
|
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-narrow page-body">
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
|
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
|
{connected ? 'Live' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load the guild board right now." />}
|
|
|
|
{!loading && !error && (
|
|
<>
|
|
{board.length === 0 ? (
|
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
|
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
|
</section>
|
|
) : (
|
|
<>
|
|
{joins.length > 0 && (
|
|
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
|
Recently joined
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
{joins.map((j) => (
|
|
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
|
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
|
<span className="dim"> joined </span>
|
|
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
|
{board.length} guilds · {totalMembers.toLocaleString()} members
|
|
</p>
|
|
<input
|
|
className="input sans"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Search guilds…"
|
|
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
|
</div>
|
|
{filtered.length === 0 && (
|
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|