Per request, split the single public house registry into three role-scoped views: - Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/ coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger"; kept live via the public house.decay feed. The full-registry deltas (house.update / house.remove — which carry owner/price) are REMOVED from the public SSE allowlist so they never reach the public channel. - Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS) backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay and search, kept live on the admin SSE channel. - Player portal → "My houses" home-status section (own houses only, with decay/ IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts. Server tests green, client build clean, swagger regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
4.2 KiB
JavaScript
92 lines
4.2 KiB
JavaScript
import { useMemo } 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'
|
|
|
|
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
|
// price, decay detail and the full registry are staff-only (admin Houses view).
|
|
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
|
// house entering IDOC appears, one leaving it drops off.
|
|
const HOUSE_KINDS = new Set(['house.decay'])
|
|
|
|
function HouseRow({ h }) {
|
|
return (
|
|
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<span
|
|
aria-hidden="true"
|
|
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
|
/>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{h.region || 'The wilderness'}
|
|
</div>
|
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
|
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
|
</div>
|
|
</div>
|
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
|
IDOC
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Houses() {
|
|
const { loading, error, data } = useAsync(() => api.shard.houses())
|
|
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
|
|
|
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
|
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
|
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.decay' || !ev.serial) continue
|
|
if (String(ev.to).toUpperCase() === 'IDOC') {
|
|
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
|
} else {
|
|
map.delete(ev.serial)
|
|
}
|
|
}
|
|
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
|
}, [data, events])
|
|
|
|
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 in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
|
<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 houses board 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 collapsing right now.</p>
|
|
</section>
|
|
) : (
|
|
<>
|
|
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
|
{board.length} in danger
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
|
</div>
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|