Two lines only core cannot supply for itself.
`uo.guild.header` is a third declared slot, at the top of the page, for core's
per-Team notification control. A third rather than a corner of the feed because a
slot holds one component and the first fill wins: the control is an action ON this
page and the other two are content IN it, and separate slots are what let this
module say so.
`pageUrlTemplate` tells core where a guild page actually is. Teams are a contract
primitive with no core surface — core owns the tables and the access rules, this
module owns the word "guild" and therefore the page — which leaves core unable to
write a link to one. A notification email that cannot take you to the thread it is
about is most of the way to useless. Core substitutes `{externalId}` and does
nothing else with it; a template naming its own host is refused at registration.
Co-Authored-By: Claude <noreply@anthropic.com>
123 lines
5.5 KiB
JavaScript
123 lines
5.5 KiB
JavaScript
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>
|
|
|
|
{/* A third place for core, up here rather than below the roster: core
|
|
puts this guild's notification control in it, and a control that
|
|
acts on the page belongs beside the page's title and not after its
|
|
content. Empty for a visitor with no membership, and on a core
|
|
that fills nothing. */}
|
|
<Slot name="uo.guild.header" externalId={String(id)} moduleId="uo" />
|
|
|
|
{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" />
|
|
|
|
{/* And the Team forum, in its own place below the feed. Core resolves
|
|
who may read it — membership and manual grants are core's rules —
|
|
so this module renders the room and never its door policy. */}
|
|
<Slot name="uo.guild.forum" externalId={String(id)} moduleId="uo" />
|
|
</>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|