feat(guilds): a guild detail page, and the slot core puts the feed in
The module's half of the org lead's correction: Teams is the contract, guilds are the presentation, and the presentation is this module's. Adds `/uo/guilds/:id` — the detail view the board never had — with the roster from this module's OWN board, which is the same data it answers core's Team provider from. Reading core's projection of our own answer back would be a round trip through a staler copy of it. The page declares `uo.guild.detail` and core fills it with the Team activity feed. That is the one part of this page core cannot hand over: only core can resolve whether the viewer is inside the Team, and the public/members split on that feed is a security boundary. The guild is named in OUR terms — core maps its own Team from the module id and the external id — so this module never holds core's row id or slug. `TeamOverviewStrip` is deleted with the core Team page it filled. `team.member.row` is not declared here either: the useful thing to put in a roster row is a link to the character behind it, and nothing core could supply identifies one. `GET /public/shard/guilds/:id` backs the page, gated and projected through the same `guilds` feature as the board — so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin. A roster is where those appear in bulk, which makes this the endpoint where getting the projection wrong would matter most. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ export const shard = {
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||
|
||||
// What this module contributes to a core Team page (`team.overview`,
|
||||
// TEAMS.md §3.3, §3.4).
|
||||
//
|
||||
// **Core already renders an online count, and this does not replace it.** Core's
|
||||
// number is written by the Team sync from the roster the module answered, so it
|
||||
// is durable and refreshed at the reconcile interval — coarse by construction.
|
||||
// This one is live: the same `presence.online` feed the site header's widget
|
||||
// already consumes, which this module holds and core does not. Core's is the
|
||||
// floor; this is the current reading, and it says which it is rather than
|
||||
// silently disagreeing with the number three lines above it.
|
||||
//
|
||||
// It is emphatically NOT a per-Team presence feed. The shard publishes a global
|
||||
// online aggregate and no per-guild breakdown exists on the wire, so claiming one
|
||||
// here would be inventing a number. What it can honestly say is how many players
|
||||
// are on the shard right now, next to a roster whose own online marks are as old
|
||||
// as the last sync — which is the context a reader of that roster is missing.
|
||||
//
|
||||
// Renders nothing at all until the feed produces something. An empty slot is the
|
||||
// correct output when there is nothing true to add (§3.7): core's page is
|
||||
// complete without it, and a panel reading "unavailable" would be this module
|
||||
// making core's page worse than it is with no module installed.
|
||||
|
||||
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||
|
||||
export default function TeamOverviewStrip() {
|
||||
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 2 })
|
||||
const snapshot = events[0]
|
||||
|
||||
const total = useMemo(() => {
|
||||
const n = Number(snapshot?.count)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}, [snapshot])
|
||||
|
||||
// No feed yet, a disabled integration, or a shard that is down. All three are
|
||||
// "nothing to add", and none of them is worth a box saying so.
|
||||
if (total == null) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
className="sans dim"
|
||||
style={{ margin: '0 0 8px', fontSize: '0.86rem' }}
|
||||
>
|
||||
{total === 0
|
||||
? 'Nobody is on the shard right now.'
|
||||
: `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
|
||||
{' '}
|
||||
<span style={{ opacity: 0.7 }}>
|
||||
The per-member marks above are as recent as the last roster sync.
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
|
||||
)
|
||||
}
|
||||
|
||||
// The curated kit (§3.4). Seven members, closed: anything else this module needs
|
||||
// The curated kit (§3.4). Eight members, closed: anything else this module needs
|
||||
// it bundles itself, which is why `components/` next door exists at all.
|
||||
export const {
|
||||
PublicLayout,
|
||||
@@ -59,6 +59,11 @@ export const {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
|
||||
// direction — this module declares a place on its own page and CORE fills it.
|
||||
// Shared rather than reimplemented so core's content failing inside our page is
|
||||
// contained by core's own error boundary.
|
||||
Slot,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
|
||||
@@ -26,6 +26,7 @@ import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Guild from './routes/public/Guild.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
@@ -52,7 +53,6 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
||||
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
||||
import TeamOverviewStrip from './components/TeamOverviewStrip.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
@@ -82,6 +82,7 @@ registry.registerRoutes(ID, {
|
||||
{ path: 'shard/activity', element: <ShardActivity /> },
|
||||
{ path: 'champs', element: <ChampSpawns /> },
|
||||
{ path: 'guilds', element: <Guilds /> },
|
||||
{ path: 'guilds/:id', element: <Guild /> },
|
||||
{ path: 'governors', element: <Governors /> },
|
||||
{ path: 'houses', element: <Houses /> },
|
||||
{ path: 'rules', element: <Rules /> },
|
||||
@@ -181,18 +182,17 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
|
||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
||||
// The fourth, and the first that was never core's: `team.overview` is new in
|
||||
// 1.6.0 and core renders the whole Team page without it (TEAMS.md §3.4). This
|
||||
// adds a live reading beside core's stored one, and renders nothing when it has
|
||||
// nothing true to say.
|
||||
// ── The inverted slot: this module DECLARES, core fills ────────────────────
|
||||
//
|
||||
// `team.member.row` is declared by core and deliberately LEFT UNFILLED. Its
|
||||
// useful contents would be a link to the character behind a roster row, and the
|
||||
// props core can supply do not identify one: the member key and the site account
|
||||
// id are withheld from every public roster (§3.2), so this module would be
|
||||
// guessing from a display name. Filling it with a guess is worse than an empty
|
||||
// cell.
|
||||
registry.registerExtension(ID, 'team.overview', TeamOverviewStrip)
|
||||
// The other three above are core's slots that this module fills. This one is the
|
||||
// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
|
||||
// populates, but core does not own the word "guild" and publishes no Team page of
|
||||
// its own — so the page is ours and core contributes the activity feed to it.
|
||||
//
|
||||
// Declared under this module's own namespace, which core enforces. Core's fill is
|
||||
// applied after every module chunk has evaluated, so declaring it here is early
|
||||
// enough; on a core that knows nothing of Teams it simply stays empty.
|
||||
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
||||
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check here. It is logged because a
|
||||
|
||||
110
client/src/routes/public/Guild.jsx
Normal file
110
client/src/routes/public/Guild.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
|
||||
|
||||
// One guild: its roster, and the place core puts the Team activity feed.
|
||||
//
|
||||
// **This page is the reason the extension-slot direction inverts**
|
||||
// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
|
||||
// module is what populates them — but core does not own the word "guild", so it
|
||||
// publishes no Team page of its own. The page is this module's; the activity feed
|
||||
// on it is core's, because only core can resolve whether the viewer is inside the
|
||||
// Team, and the public/members split on that feed is a security boundary.
|
||||
//
|
||||
// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
|
||||
// core that does not know about Teams the slot is simply never filled and this
|
||||
// page renders its roster alone, which is the same tolerance every other slot has.
|
||||
//
|
||||
// The roster comes from this module's OWN board — the same data it answers core's
|
||||
// Team provider from — rather than from core's Team API. That is deliberate: the
|
||||
// board is the authoritative copy here, and reading core's projection of our own
|
||||
// answer back would be a round trip through a staler copy of our own data.
|
||||
|
||||
function rankOf(m) {
|
||||
// Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
|
||||
// staff, because ServUO reports GameMaster-and-above as Leader whatever their
|
||||
// real rank — emitting that verbatim would publish every staff member in a
|
||||
// guild as one of its leaders (docs/link/v4.md).
|
||||
if (m.rankName) return m.rankName
|
||||
return null
|
||||
}
|
||||
|
||||
function MemberRow({ m }) {
|
||||
const rank = rankOf(m)
|
||||
const linked = m.webId != null || m.acct != null
|
||||
return (
|
||||
<tr style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '9px 10px', color: 'var(--head)' }}>
|
||||
{m.name || 'Unknown'}
|
||||
{m.rank === 4 && (
|
||||
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>{rank || '—'}</td>
|
||||
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>
|
||||
{linked ? 'Linked' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guild() {
|
||||
const { id } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.guild(id), [id])
|
||||
const roster = (data && data.roster) || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p style={{ marginBottom: 14 }}>
|
||||
<Link to="/uo/guilds">← All guilds</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load this guild right now." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={data.abbr ? `[${data.abbr}]` : 'Guild'}
|
||||
title={data.name || 'A guild'}
|
||||
/>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem' }}>
|
||||
{data.members ?? roster.length} members
|
||||
{data.online != null && ` · ${data.online} online`}
|
||||
{data.alliance && ` · ${data.alliance}`}
|
||||
</p>
|
||||
|
||||
{roster.length > 0 && (
|
||||
<div style={{ overflowX: 'auto', marginTop: 18 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Name</th>
|
||||
<th style={{ padding: '8px 10px' }}>Rank</th>
|
||||
<th style={{ padding: '8px 10px' }}>Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Keyed by serial: two characters can share a display name,
|
||||
which this shard's own world actually contains. */}
|
||||
{roster.map((m) => <MemberRow key={m.serial} m={m} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roster.length === 0 && (
|
||||
<p className="sans dim" style={{ marginTop: 18 }}>No roster has been received for this guild yet.</p>
|
||||
)}
|
||||
|
||||
{/* Core's Team activity feed lands here. Nothing renders on a core
|
||||
that does not fill it, or when there is nothing to show. The guild
|
||||
is named in OUR terms — core maps its own Team from these two. */}
|
||||
<Slot name="uo.guild.detail" externalId={String(id)} moduleId="uo" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
@@ -15,9 +16,12 @@ function Leader({ leader }) {
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
// A link now, because the board gained a detail page: the roster and core's
|
||||
// Team activity feed live there (docs/website/TEAMS.md Part 3).
|
||||
<Link
|
||||
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ function fakeRg() {
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const extensions = new Map()
|
||||
const declaredSlots = new Set()
|
||||
return {
|
||||
version: '1.3.0',
|
||||
react,
|
||||
@@ -54,7 +55,7 @@ function fakeRg() {
|
||||
// object, so the check compares against whatever is here.
|
||||
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
|
||||
ui: Object.fromEntries(
|
||||
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
|
||||
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
|
||||
.map((n) => [n, stub(n)]),
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
@@ -72,10 +73,19 @@ function fakeRg() {
|
||||
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||
extensions.set(slot, { id, Component })
|
||||
},
|
||||
// The INVERTED direction (core API 1.6.0): this module declares a place on
|
||||
// its OWN page and core fills it. Core enforces the namespace, so the fake
|
||||
// does too — a chunk that declared an unnamespaced slot would pass here and
|
||||
// throw in a browser.
|
||||
declareModuleSlot(id, name) {
|
||||
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||
declaredSlots.add(name)
|
||||
},
|
||||
routesFor: (area) => routes[area],
|
||||
navFor: (area) => nav[area],
|
||||
},
|
||||
_read: () => ({ routes, nav, providers, extensions }),
|
||||
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +108,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
|
||||
|
||||
it('registers routes in all three areas, namespaced under the module id', () => {
|
||||
const { routes } = registered
|
||||
assert.equal(routes.public.length, 12)
|
||||
assert.equal(routes.public.length, 13)
|
||||
assert.equal(routes.admin.length, 7)
|
||||
assert.equal(routes.player.length, 2)
|
||||
for (const area of ['public', 'admin', 'player']) {
|
||||
@@ -166,15 +176,11 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
|
||||
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('fills the four extension slots, each with a component', () => {
|
||||
it('fills the three CORE extension slots, each with a component', () => {
|
||||
const { extensions } = registered
|
||||
// `team.member.row` is core-declared and deliberately absent: the props core
|
||||
// can supply do not identify a character, because the member key and the site
|
||||
// account id are withheld from every public roster (TEAMS.md §3.2). An empty
|
||||
// cell beats a guess.
|
||||
assert.deepEqual(
|
||||
[...extensions.keys()].sort(),
|
||||
['admin.users.detail', 'player.invite.accepted', 'site.footer.status', 'team.overview'],
|
||||
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
|
||||
)
|
||||
for (const [slot, { id, Component }] of extensions) {
|
||||
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
|
||||
@@ -202,3 +208,17 @@ it('registers under exactly one module id, matching the manifest', () => {
|
||||
])
|
||||
assert.deepEqual([...owners], [manifest.id])
|
||||
})
|
||||
|
||||
it('declares its own guild-detail slot, for core to fill', () => {
|
||||
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
|
||||
// core page: core owns the activity feed and this module owns the word "guild",
|
||||
// so this module declares the place and core puts the feed in it.
|
||||
assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail'])
|
||||
})
|
||||
|
||||
it('the declared slot is rendered by the page that owns it', () => {
|
||||
// A slot nothing renders is a slot core fills into the void. Asserted against
|
||||
// the source rather than the chunk, since the chunk is minified.
|
||||
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
|
||||
assert.match(page, /name="uo\.guild\.detail"/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user