feat(shard): Protocol 2.0 boards UI — guilds, governors, houses, players-online
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>
This commit is contained in:
156
client/src/routes/public/Houses.jsx
Normal file
156
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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 house registry. Loaded from /public/shard/houses, kept live by merging
|
||||
// house.update / house.remove deltas by serial. `price` is the placement value —
|
||||
// NOT a for-sale flag (stock ServUO has none), and the UI labels it as such.
|
||||
const HOUSE_KINDS = new Set(['house.update', 'house.remove'])
|
||||
|
||||
// Decay level → colour, from healthiest to collapsed.
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4',
|
||||
Slightly: '#a9cf8a',
|
||||
Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f',
|
||||
Greatly: '#d9736f',
|
||||
IDOC: '#e05a5a',
|
||||
Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
function DecayBadge({ decay, isIdoc }) {
|
||||
const label = isIdoc ? 'IDOC' : decay
|
||||
if (!label) return null
|
||||
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||
return (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.04em', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// house.update carries owner as a flattened ownerName/ownerAcct on our shaped row.
|
||||
function ownerLabel(h) {
|
||||
return h.ownerName || h.ownerAcct || null
|
||||
}
|
||||
|
||||
function HouseRow({ h }) {
|
||||
const owner = ownerLabel(h)
|
||||
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: 'center', gap: 8, minWidth: 0 }}>
|
||||
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.name || 'An unnamed house'}
|
||||
</strong>
|
||||
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{h.price != null && (
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{Number(h.price).toLocaleString()}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
placement value
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Houses() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'house.update' && ev.serial) {
|
||||
// Live house.update events arrive in the sidecar's shape (owner is an
|
||||
// actor object); normalize to the flattened shape the row renders.
|
||||
map.set(ev.serial, {
|
||||
...ev,
|
||||
ownerName: ev.owner?.name ?? ev.ownerName,
|
||||
ownerAcct: ev.owner?.acct ?? ev.ownerAcct,
|
||||
})
|
||||
} else if (ev.kind === 'house.remove' && ev.serial) {
|
||||
map.delete(ev.serial)
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((h) =>
|
||||
[h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)),
|
||||
)
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
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="Houses" lead="The house registry — owners, sizes and standing across Britannia." />
|
||||
<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 house registry right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||
</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.toLocaleString()} houses
|
||||
</p>
|
||||
<input
|
||||
className="input sans"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search by owner, region…"
|
||||
style={{ flex: 'none', width: 210, maxWidth: '55%', fontSize: '0.84rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user