refactor(teams)!: Teams is a contract, not a surface — invert the slots
Org lead's correction, and it changes what this phase ships.
TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.
Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.
Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.
**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.
Three things about the inverted direction are load-bearing:
- the name is namespaced under the declaring module and that is enforced, not
conventional: it is the only thing keeping two modules off one name;
- core's fills are applied at MOUNT rather than eagerly. Core's bundle
evaluates before every module chunk, so when core registers a fill the slot
does not exist yet — filling eagerly would silently do nothing;
- a fill for a slot nobody declared is a no-op, never an error. The declaring
module is simply not installed, which is the ordinary case. That is the
opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
there, core declares first, so an unknown name is always a typo.
`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.
`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,9 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Teams from './routes/public/Teams.jsx'
|
||||
import Team from './routes/public/Team.jsx'
|
||||
import TeamRoster from './routes/public/TeamRoster.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -60,7 +57,6 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
import PlayerTeams from './routes/player/PlayerTeams.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -97,13 +93,6 @@ export default function App() {
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* Teams (TEAMS.md §3.1). Core routes, not module ones: a Team is a
|
||||
core platform entity that a module merely populates, so these
|
||||
render on bare core too. `/teams` is declared before `/:slug`
|
||||
below for the same reason every named route is. */}
|
||||
<Route path="/teams" element={<Teams />} />
|
||||
<Route path="/teams/:slug" element={<Team />} />
|
||||
<Route path="/teams/:slug/roster" element={<TeamRoster />} />
|
||||
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
||||
registry prefixes the segment, so a module cannot spell its way
|
||||
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
||||
@@ -227,11 +216,6 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Core's own page under the /player prefix, unlike the module
|
||||
pages below it. Open to any authenticated account, not role
|
||||
'player': staff are a superset of players and a moderator is in
|
||||
guilds too — RequirePlayer above already draws that line. */}
|
||||
<Route path="/player/teams" element={<PlayerTeams />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -134,28 +134,24 @@ export const api = {
|
||||
},
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
|
||||
// ----- Teams (TEAMS.md §2.11, §3.1) -----
|
||||
// ----- Teams (TEAMS.md §2.11, §4.3) -----
|
||||
//
|
||||
// Public reads, but three of them behave differently for a signed-in caller and
|
||||
// the session rides along on the cookie the shared `req` already sends: the
|
||||
// roster may widen with the module's audience projection, and the activity feed
|
||||
// adds members-only items. None of them REQUIRES a session.
|
||||
teams: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit != null) qs.set('limit', String(opts.limit))
|
||||
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
||||
return req(`/public/teams${withQs(qs.toString())}`)
|
||||
},
|
||||
team: (slug) => req(`/public/teams/${encodeURIComponent(slug)}`),
|
||||
teamRoster: (slug) => req(`/public/teams/${encodeURIComponent(slug)}/members`),
|
||||
// Only the two calls CORE's own client makes. Core renders no Team pages — the
|
||||
// vocabulary belongs to whichever module owns the surface — so the index, the
|
||||
// roster and the player list are not here; a module that renders those calls
|
||||
// the same public API from its own client.
|
||||
//
|
||||
// The lookup exists because a module names a Team in its own terms and core
|
||||
// keys the feed by slug. Resolving that is core's job precisely so a module
|
||||
// never has to hold core's identifiers.
|
||||
teamByExternalId: (moduleId, externalId) =>
|
||||
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
|
||||
teamActivity: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit != null) qs.set('limit', String(opts.limit))
|
||||
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
||||
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
|
||||
},
|
||||
myTeams: () => req('/player/teams'),
|
||||
myTeamAccess: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/access`),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
|
||||
@@ -28,11 +28,6 @@ import { useFeatureGate } from '../modules/features.jsx'
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
// The first CORE row to carry a `feature` since the shard rows left in slice 3
|
||||
// (TEAMS.md §3.5). It is answered by core's own provider (main.jsx) and gates
|
||||
// on whether this deployment has Teams at all, not on who is looking — Team
|
||||
// pages are public. Fails open, so an unknown answer shows the link.
|
||||
{ label: 'Teams', to: '/teams', feature: 'teams' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
|
||||
100
client/src/lib/teamActivity.js
Normal file
100
client/src/lib/teamActivity.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// What core's Team activity feed SAYS, separated from how it renders
|
||||
// (docs/website/TEAMS.md §4.3).
|
||||
//
|
||||
// Core renders this feed into a slot a MODULE declares on its own page, because
|
||||
// Teams is a contract primitive and not a surface: core owns the feed, its
|
||||
// visibility rules and its wording; the module owns the page and the vocabulary
|
||||
// around it. So this file is deliberately narrow — the roster and index
|
||||
// presentation that once lived here went with the core Team pages, to whichever
|
||||
// module renders them.
|
||||
//
|
||||
// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
|
||||
// reason it was there: a feed that is filtered, or a projection that is stale,
|
||||
// has to say so in words, and getting that wording right is logic rather than
|
||||
// markup.
|
||||
|
||||
const MINUTE = 60_000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
|
||||
export function relativeTime(when, now = Date.now()) {
|
||||
if (!when) return null
|
||||
const ms = now - new Date(when).getTime()
|
||||
if (!Number.isFinite(ms)) return null
|
||||
if (ms < MINUTE) return 'just now'
|
||||
if (ms < HOUR) {
|
||||
const n = Math.floor(ms / MINUTE)
|
||||
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
|
||||
}
|
||||
if (ms < DAY) {
|
||||
const n = Math.floor(ms / HOUR)
|
||||
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
|
||||
}
|
||||
const n = Math.floor(ms / DAY)
|
||||
return `${n} ${n === 1 ? 'day' : 'days'} ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* How a public surface describes the projection's freshness (§2.4).
|
||||
*
|
||||
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
|
||||
* debugging a sync. A visitor needs one sentence about whether what they are
|
||||
* looking at is current, and specifically must never be shown an unconfirmed
|
||||
* empty projection as though it were a confirmed empty shard.
|
||||
*/
|
||||
export function freshnessNote(sync = {}, now = Date.now()) {
|
||||
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
|
||||
// deployment with no game module is not a broken one.
|
||||
if (!sync.configured) return null
|
||||
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
|
||||
const ago = relativeTime(sync.lastSyncAt, now)
|
||||
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
|
||||
return { tone: 'idle', text: `Last confirmed ${ago}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group feed items into days, newest first, preserving order within a day (§4.3).
|
||||
*
|
||||
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
|
||||
* property of where the reader is sitting, and a shard's evening raid landing at
|
||||
* 00:30 UTC belongs on the day the players experienced it.
|
||||
*/
|
||||
export function groupByDay(items = [], locale = undefined) {
|
||||
const days = []
|
||||
const byKey = new Map()
|
||||
for (const item of items) {
|
||||
const date = new Date(item.occurredAt)
|
||||
if (Number.isNaN(date.getTime())) continue
|
||||
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
||||
if (!byKey.has(key)) {
|
||||
const day = {
|
||||
key,
|
||||
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
items: [],
|
||||
}
|
||||
byKey.set(key, day)
|
||||
days.push(day)
|
||||
}
|
||||
byKey.get(key).items.push(item)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/**
|
||||
* What to say under a feed that has been filtered.
|
||||
*
|
||||
* Only when there is something to say: a caller who saw everything is told
|
||||
* nothing, and an anonymous caller is invited to sign in rather than simply
|
||||
* informed that entries exist which they cannot have.
|
||||
*
|
||||
* The wording avoids core's own noun. The reader is looking at a page the module
|
||||
* titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
|
||||
* onto a surface that deliberately does not use it.
|
||||
*/
|
||||
export function activityScopeNote(feed = {}, signedIn = false) {
|
||||
if (feed.scope !== 'public') return null
|
||||
return signedIn
|
||||
? 'Some entries are visible to members only.'
|
||||
: 'Sign in as a member to see the members-only entries.'
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// What the public Team pages SAY, separated from how they render
|
||||
// (docs/website/TEAMS.md §3.2, §3.3, §4.3).
|
||||
//
|
||||
// Plain JS with tests, following lib/teamAdmin.js. It is worth splitting here for
|
||||
// the same reason it was there: these pages have to state differences that look
|
||||
// like bugs unless they are worded deliberately. A roster header reading
|
||||
// "37 members · 21 linked" is information; the same numbers with no explanation
|
||||
// is a support ticket. And an empty roster has three unrelated causes — a Team
|
||||
// with nobody in it, an audience rung that shows nobody, and a module that could
|
||||
// not be asked — which is logic, not markup.
|
||||
|
||||
/** How the roster describes a row's relationship to a site account (§3.2). */
|
||||
export const LINK_STATE = { linked: 'linked', unlinked: 'unlinked' }
|
||||
|
||||
export function linkStateOf(member) {
|
||||
return member && member.linked ? LINK_STATE.linked : LINK_STATE.unlinked
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster header line.
|
||||
*
|
||||
* The gap between the two numbers is the surfaced divergence Part 2 asks for: it
|
||||
* must read as information rather than as a discrepancy, which is why the line
|
||||
* says what each number IS instead of showing them side by side and hoping.
|
||||
*
|
||||
* `guests` is phase 4's forum grants and is omitted while there are none, so the
|
||||
* line does not carry a permanent zero for a feature that has not shipped.
|
||||
*/
|
||||
export function rosterSummary({ members = 0, linked = 0, guests = 0 } = {}) {
|
||||
const parts = [`${members} ${members === 1 ? 'member' : 'members'}`, `${linked} linked`]
|
||||
if (guests > 0) parts.push(`${guests} forum ${guests === 1 ? 'guest' : 'guests'}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
const MINUTE = 60_000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
|
||||
export function relativeTime(when, now = Date.now()) {
|
||||
if (!when) return null
|
||||
const ms = now - new Date(when).getTime()
|
||||
if (!Number.isFinite(ms)) return null
|
||||
if (ms < MINUTE) return 'just now'
|
||||
if (ms < HOUR) {
|
||||
const n = Math.floor(ms / MINUTE)
|
||||
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
|
||||
}
|
||||
if (ms < DAY) {
|
||||
const n = Math.floor(ms / HOUR)
|
||||
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
|
||||
}
|
||||
const n = Math.floor(ms / DAY)
|
||||
return `${n} ${n === 1 ? 'day' : 'days'} ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* How a public page describes the projection's freshness (§2.4).
|
||||
*
|
||||
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
|
||||
* debugging a sync. A visitor needs one sentence about whether what they are
|
||||
* looking at is current, and specifically must never be shown an unconfirmed
|
||||
* empty projection as though it were a confirmed empty shard.
|
||||
*/
|
||||
export function freshnessNote(sync = {}, now = Date.now()) {
|
||||
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
|
||||
// deployment with no game module is not a broken one.
|
||||
if (!sync.configured) return null
|
||||
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
|
||||
const ago = relativeTime(sync.lastSyncAt, now)
|
||||
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
|
||||
return { tone: 'idle', text: `Last confirmed ${ago}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a roster is empty, in the viewer's terms.
|
||||
*
|
||||
* Returns null when it is not empty. The three causes are genuinely different and
|
||||
* reporting the wrong one is the failure this function exists to prevent: telling
|
||||
* someone a guild has no members when in fact the module could not be asked is a
|
||||
* statement about the game that happens to be false.
|
||||
*/
|
||||
export function emptyRosterReason(roster = {}) {
|
||||
const members = roster.members || []
|
||||
if (members.length) return null
|
||||
if (roster.projectionUnavailable) {
|
||||
return 'The roster cannot be shown right now — the game module could not be reached.'
|
||||
}
|
||||
if (roster.configured && !roster.lastSyncAt) {
|
||||
return 'This roster has not been confirmed against the game yet.'
|
||||
}
|
||||
return 'Nobody is in this Team.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Group feed items into days, newest first, preserving order within a day (§4.3).
|
||||
*
|
||||
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
|
||||
* property of where the reader is sitting, and a shard's evening raid landing at
|
||||
* 00:30 UTC belongs on the day the players experienced it.
|
||||
*/
|
||||
export function groupByDay(items = [], locale = undefined) {
|
||||
const days = []
|
||||
const byKey = new Map()
|
||||
for (const item of items) {
|
||||
const date = new Date(item.occurredAt)
|
||||
if (Number.isNaN(date.getTime())) continue
|
||||
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
||||
if (!byKey.has(key)) {
|
||||
const day = {
|
||||
key,
|
||||
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
items: [],
|
||||
}
|
||||
byKey.set(key, day)
|
||||
days.push(day)
|
||||
}
|
||||
byKey.get(key).items.push(item)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/**
|
||||
* What to say under a feed that has been filtered.
|
||||
*
|
||||
* Only when there is something to say: a caller who saw everything is told
|
||||
* nothing, and an anonymous caller is invited to sign in rather than simply
|
||||
* informed that items exist which they cannot have.
|
||||
*/
|
||||
export function activityScopeNote(feed = {}, signedIn = false) {
|
||||
if (feed.scope !== 'public') return null
|
||||
return signedIn
|
||||
? 'Some entries are visible to members of this Team only.'
|
||||
: 'Sign in as a member of this Team to see its members-only entries.'
|
||||
}
|
||||
|
||||
/** Sort for the index: most members first, then alphabetically. */
|
||||
export function sortTeams(teams = []) {
|
||||
return [...teams].sort(
|
||||
(a, b) => (b.memberCount || 0) - (a.memberCount || 0) || String(a.name).localeCompare(String(b.name)),
|
||||
)
|
||||
}
|
||||
|
||||
/** The index's search, over the two things a visitor knows a Team by. */
|
||||
export function filterTeams(teams = [], query = '') {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return teams
|
||||
return teams.filter(
|
||||
(t) => String(t.name || '').toLowerCase().includes(q) || String(t.abbr || '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot, registerFeatureProvider } from './modules/registry.js'
|
||||
import { useCoreFlags } from './modules/coreFeatures.js'
|
||||
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -19,12 +19,6 @@ publishSharedDependencies()
|
||||
// and namespace `uo`, so that the seam was exercised by real content from the
|
||||
// day it was built. That prediction paid out exactly as written: the extraction
|
||||
// deleted the registration and the hook it named, and SiteHeader was not touched.
|
||||
//
|
||||
// Teams put a core row back on the seam. `feature: 'teams'` on the three Team nav
|
||||
// rows resolves against owner id `core` (featureGate.js: a row with no `moduleId`
|
||||
// belongs to core), and this is the provider that answers it — hiding the rows on
|
||||
// a deployment that has no Teams at all, and failing open everywhere else.
|
||||
registerFeatureProvider('core', 'core', useCoreFlags)
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
@@ -55,24 +49,32 @@ declareSlot('admin.users.detail')
|
||||
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
|
||||
// which is what core's own code did whenever the flag was off.
|
||||
declareSlot('player.invite.accepted')
|
||||
// The two Team slots (TEAMS.md §3.4, MODULE_API 1.6.0). Both named for a place:
|
||||
// `team.overview` is the spot under the counts on a Team page, not "where the
|
||||
// game puts guild stats", and `team.member.row` is the trailing cell of a roster
|
||||
// row. Core renders the whole Team experience with both unfilled — the pages are
|
||||
// core's, and a module adds to them rather than supplying them.
|
||||
//
|
||||
// `team.overview` is where a live "online now" strip belongs: core's online count
|
||||
// is the durable floor refreshed at the reconcile interval (§3.3), and a module
|
||||
// that already holds a live presence feed can render the current number here
|
||||
// without core acquiring an SSE stack to do it.
|
||||
declareSlot('team.overview')
|
||||
declareSlot('team.member.row')
|
||||
|
||||
// Core filled the first two itself until slice 3, with the components that were
|
||||
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
|
||||
// all three, and core's own fills had to go for it to be able to — the first
|
||||
// fill wins, and core registered first (§3.7).
|
||||
|
||||
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
|
||||
//
|
||||
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
|
||||
// tables, the sync, the access rules and the activity feed; it does not own the
|
||||
// word for one — a UO shard says guild, and the module that comes after it will
|
||||
// say clan. So core publishes no Team page and no Team nav row, and the module
|
||||
// that owns the vocabulary owns the page.
|
||||
//
|
||||
// The activity feed is the one piece of that page core cannot hand over: only
|
||||
// core can resolve whether this viewer is inside the Team, and the public/members
|
||||
// split is a security boundary. So the module declares the place and core fills
|
||||
// it. Registered here, applied at mount — `applyCoreFills` runs after every
|
||||
// module chunk has evaluated, which is the only moment a module-declared slot
|
||||
// exists to be filled.
|
||||
//
|
||||
// Naming a slot no installed module declares is not an error. On a deployment
|
||||
// with no game module this fill simply never lands, which is the mirror of an
|
||||
// unfilled slot rendering nothing.
|
||||
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
@@ -99,6 +101,10 @@ declareSlot('team.member.row')
|
||||
// static deferred script, so this branch is the genuine "the event has already
|
||||
// been and gone" case and not a wrong guess about our own timing.
|
||||
function mount() {
|
||||
// Every module chunk has evaluated by now, so any slot a module declared is
|
||||
// present and core's pending fills can land. Must happen before the first
|
||||
// render: `extensionFor` is read during render and there is no subscription.
|
||||
applyCoreFills()
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
96
client/src/modules/TeamActivityFeed.jsx
Normal file
96
client/src/modules/TeamActivityFeed.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
|
||||
|
||||
// Core's Team activity feed, rendered into a slot a MODULE declares
|
||||
// (TEAMS.md Part 4, §3.4 as amended).
|
||||
//
|
||||
// **This is the inverted slot direction, and this component is why it exists.**
|
||||
// The feed is core's: core owns `team_activity`, writes the membership and rename
|
||||
// items into it, enforces the public/members split, and is the only thing that
|
||||
// can resolve whether this viewer is inside the Team. None of that is a module's
|
||||
// to reimplement. But the PAGE is the module's, because Teams is a contract
|
||||
// primitive and core does not own the word for one — a UO shard says guild, the
|
||||
// next game will say something else. So the module declares the place and core
|
||||
// puts the feed in it.
|
||||
//
|
||||
// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
|
||||
// id — and core resolves the slug. A module never learns core's Team id and never
|
||||
// needs to: it names the thing the way it already names it.
|
||||
//
|
||||
// Everything here degrades to rendering nothing. A slot that throws is contained
|
||||
// by core's own boundary (Slot.jsx), but a slot that renders an error box would
|
||||
// still be core putting a defect on a page it does not own — so a failed fetch is
|
||||
// silence, not a message.
|
||||
|
||||
export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
|
||||
const { user } = useAuth()
|
||||
const [state, setState] = useState({ loading: true, feed: null, team: null })
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!externalId || !moduleId) {
|
||||
setState({ loading: false, feed: null, team: null })
|
||||
return undefined
|
||||
}
|
||||
// Two calls because the module names the Team its way and the feed is keyed
|
||||
// by core's slug. The lookup is core's job precisely so the module does not
|
||||
// have to hold core's identifiers.
|
||||
api.teamByExternalId(moduleId, externalId)
|
||||
.then(async (team) => {
|
||||
const feed = await api.teamActivity(team.slug, { limit })
|
||||
if (active) setState({ loading: false, feed, team })
|
||||
})
|
||||
.catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
|
||||
return () => { active = false }
|
||||
}, [externalId, moduleId, limit])
|
||||
|
||||
const { loading, feed, team } = state
|
||||
if (loading || !feed) return null
|
||||
|
||||
const days = groupByDay(feed.items || [])
|
||||
const note = team ? freshnessNote(team) : null
|
||||
const scopeNote = activityScopeNote(feed, Boolean(user))
|
||||
|
||||
// Nothing has happened and nothing to explain: render nothing rather than an
|
||||
// empty heading on someone else's page.
|
||||
if (days.length === 0 && !scopeNote) return null
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', marginBottom: 4 }}>
|
||||
Recent activity
|
||||
</h2>
|
||||
{note && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 12px' }}>{note.text}</p>
|
||||
)}
|
||||
|
||||
{days.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>Nothing has happened here yet.</p>
|
||||
)}
|
||||
|
||||
{days.map((day) => (
|
||||
<div key={day.key} style={{ marginBottom: 16 }}>
|
||||
<h3
|
||||
className="sans dim"
|
||||
style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}
|
||||
>
|
||||
{day.label}
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
|
||||
{day.items.map((item) => (
|
||||
<li key={item.id} className="sans" style={{ fontSize: '0.92rem', color: 'var(--ink)' }}>
|
||||
{item.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scopeNote && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>{scopeNote}</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Core's own feature provider (TEAMS.md §3.5, MODULE_API.md §3.3).
|
||||
//
|
||||
// Core registered one here until the module cutover, under owner id `core` and
|
||||
// namespace `uo`, and it left with the shard rows. This brings the seam back with
|
||||
// content that is genuinely core's: `teams` gates the Teams nav rows, and Teams
|
||||
// are a core platform entity that a module merely populates.
|
||||
//
|
||||
// **What the flag actually answers is "does this deployment have Teams at all".**
|
||||
// Not "may this viewer see them" — Team pages are public (§0.7) and the server
|
||||
// gates them. On bare core, with no module supplying a Team provider and no rows
|
||||
// left behind by one, `/teams` is a permanently empty page and a link to it is
|
||||
// worse than no link. That is the whole job.
|
||||
//
|
||||
// It fails OPEN, like every other answer in this seam: while the request is in
|
||||
// flight, and on any error, the hook returns `null`, which `buildFeatureGate`
|
||||
// reads as "we do not know yet" and SHOWS the row. The page itself is the gate.
|
||||
// The one thing a UI mistake must never do here is hide a surface from someone
|
||||
// entitled to it — and a Teams link that leads somewhere empty is a far cheaper
|
||||
// mistake than a Team page nobody can find.
|
||||
|
||||
// `limit=1` because only `enabled` is wanted. The endpoint answers it whatever
|
||||
// the page size, and asking for the default fifty would pull a roster's worth of
|
||||
// counts into a nav decision.
|
||||
const TEAMS_URL = '/api/v1/public/teams?limit=1'
|
||||
|
||||
/**
|
||||
* The hook core registers. Returns a Set-like of visible flags, or `null` while
|
||||
* the answer is unknown.
|
||||
*
|
||||
* Fetched once per mount rather than subscribed: whether a deployment has Teams
|
||||
* changes when a module is installed, which is a restart, not a session event.
|
||||
*/
|
||||
export function useCoreFlags() {
|
||||
const [flags, setFlags] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetch(TEAMS_URL, { credentials: 'same-origin' })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => {
|
||||
if (!active) return
|
||||
// A body that does not carry `enabled` is an older server or a shape
|
||||
// change, and both are "unknown" rather than "no".
|
||||
if (!body || typeof body.enabled !== 'boolean') return
|
||||
setFlags(new Set(body.enabled ? ['teams'] : []))
|
||||
})
|
||||
.catch(() => {}) // stays null: unknown shows the row
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
export default useCoreFlags
|
||||
@@ -135,6 +135,69 @@ export function declareSlot(name) {
|
||||
slots.set(name, { Component: null, filledBy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
|
||||
*
|
||||
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
|
||||
* the page and a module contributes to it, which is right for the footer and the
|
||||
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
|
||||
* not a surface.** Core owns the tables, the sync, the access rules and the
|
||||
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
|
||||
* and the next game will call them something else — so the PAGE is the module's
|
||||
* and the content core contributes to it is core's.
|
||||
*
|
||||
* Without this, core would have to publish a `/teams` page under a word it
|
||||
* invented, next to the module's own Guilds page saying the same thing twice.
|
||||
*
|
||||
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
|
||||
* what stops two modules colliding and what makes the owner readable at the fill
|
||||
* site. The namespace is enforced rather than conventional.
|
||||
*
|
||||
* **Ordering is why this is a separate call and not just `declareSlot` exposed
|
||||
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
|
||||
* are deferred and injected after core's), so at the moment core would like to
|
||||
* fill one of these, it does not exist yet. Core therefore registers its fills
|
||||
* through `fillModuleSlot` below, which is applied after every module chunk has
|
||||
* evaluated — see main.jsx.
|
||||
*/
|
||||
export function declareModuleSlot(id, name) {
|
||||
if (!name.startsWith(`${id}.`)) {
|
||||
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||
}
|
||||
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||
slots.set(name, { Component: null, filledBy: null, declaredBy: id })
|
||||
}
|
||||
|
||||
// Core's pending fills for module-declared slots, applied once every module
|
||||
// chunk has evaluated. Kept as a list rather than applied eagerly because the
|
||||
// slot does not exist when core asks — see the ordering note above.
|
||||
const coreFills = []
|
||||
|
||||
/**
|
||||
* Core: "fill this module-declared slot when it turns up."
|
||||
*
|
||||
* Deliberately not an error when the slot never appears. A module that is not
|
||||
* installed declares nothing, and core offering content for a page that does not
|
||||
* exist is the ordinary case on any deployment — not a misconfiguration. That is
|
||||
* the mirror of an unfilled slot rendering nothing.
|
||||
*/
|
||||
export function fillModuleSlot(name, Component) {
|
||||
if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`)
|
||||
coreFills.push([name, Component])
|
||||
}
|
||||
|
||||
/** Apply core's fills. Called once from main.jsx, after module chunks have run. */
|
||||
export function applyCoreFills() {
|
||||
for (const [name, Component] of coreFills) {
|
||||
const entry = slots.get(name)
|
||||
if (!entry) continue // the declaring module is not installed
|
||||
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
||||
entry.Component = Component
|
||||
entry.filledBy = 'core'
|
||||
}
|
||||
coreFills.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a declared slot with a component.
|
||||
*
|
||||
@@ -207,6 +270,7 @@ export function _reset() {
|
||||
nav[area].length = 0
|
||||
}
|
||||
providers.clear()
|
||||
coreFills.length = 0
|
||||
// Declarations go too, unlike the server's, where a slot is declared once at
|
||||
// require time by the router that owns it. Core declares its slots in
|
||||
// main.jsx — the one file no test loads — so on this side there is nothing
|
||||
@@ -224,6 +288,8 @@ export const registry = {
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
registerExtension,
|
||||
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
|
||||
declareModuleSlot,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js'
|
||||
import PublicLayout from '../components/PublicLayout.jsx'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
||||
import Slot from './Slot.jsx'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
@@ -66,6 +67,13 @@ const ui = {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
||||
// module that declares a slot on its own page needs the same component core
|
||||
// renders its own with — the error boundary in particular, since the thing
|
||||
// being contained here is CORE's content failing inside the MODULE's page.
|
||||
// Shared rather than reimplemented for the reason the whole kit exists: two
|
||||
// boundaries with different behaviour would be two bugs.
|
||||
Slot,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
||||
|
||||
@@ -35,7 +35,6 @@ function Icon({ children, size = 16 }) {
|
||||
}
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
const IconTeams = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20v-1a5 5 0 015-5h2a5 5 0 015 5v1" /><path d="M16 5.5a3 3 0 010 5.8M18 20v-1a5 5 0 00-2-4" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
@@ -47,12 +46,6 @@ const IconTeams = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20v-1a5
|
||||
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
// Gated on the same core `teams` flag as the public header row, for the same
|
||||
// reason: on a deployment with no Teams this leads to a permanently empty page
|
||||
// (TEAMS.md §3.5). It is NOT gated on role — staff are a superset of players
|
||||
// and a moderator is in guilds too, which is the mistake this portal has
|
||||
// already made once.
|
||||
{ to: '/player/teams', label: 'My Teams', icon: IconTeams, feature: 'teams' },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
@@ -63,7 +56,6 @@ export const NAV = [
|
||||
const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/player/teams': 'My Teams',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { rosterSummary } from '../../lib/teams.js'
|
||||
|
||||
// "My Teams" (TEAMS.md §3.1) — the caller's Teams and what each one grants them.
|
||||
//
|
||||
// **Membership and a forum grant are separate authority paths**, so every row
|
||||
// carries the reason it is here. `both` is a real state and is kept: someone who
|
||||
// was granted forum access and has since joined sees membership as the current
|
||||
// reason without the grant vanishing from the record.
|
||||
//
|
||||
// A Team HIDDEN from public surfaces still appears here. Suppression is a
|
||||
// public-surface rule and a member is not a member of the public — but the page
|
||||
// says so, because a Team listed here that 404s when clicked is otherwise
|
||||
// indistinguishable from a broken link.
|
||||
|
||||
const REASON = {
|
||||
membership: { label: 'Member', detail: 'You are in this Team in-game.' },
|
||||
grant: { label: 'Forum guest', detail: 'You were granted access to this Team’s forum.' },
|
||||
both: { label: 'Member', detail: 'You are a member, and hold a forum grant as well.' },
|
||||
}
|
||||
|
||||
export default function PlayerTeams() {
|
||||
const { loading, error, data } = useAsync(() => api.myTeams())
|
||||
const teams = data?.teams || []
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load your Teams right now." />}
|
||||
|
||||
{!loading && !error && teams.length === 0 && (
|
||||
<p style={{ color: 'var(--muted)' }}>
|
||||
You are not in any Team. Teams come from the game — join a guild in-game and it will
|
||||
appear here after the next sync.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{teams.map((team) => {
|
||||
const reason = REASON[team.reason] || REASON.membership
|
||||
return (
|
||||
<div key={team.slug} className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Link to={`/teams/${team.slug}`} className="display" style={{ color: 'var(--head)', fontSize: '1.05rem' }}>
|
||||
{team.name}
|
||||
</Link>
|
||||
<span style={{ color: 'var(--accent)', fontSize: '0.8rem' }}>
|
||||
{reason.label}
|
||||
{team.isLeader && ' · Leader'}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.88rem', margin: '6px 0 0' }}>
|
||||
{reason.detail}
|
||||
</p>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.85rem', margin: '4px 0 0' }}>
|
||||
{rosterSummary({ members: team.memberCount, linked: team.linkedCount })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import Slot from '../../modules/Slot.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { activityScopeNote, freshnessNote, groupByDay, rosterSummary } from '../../lib/teams.js'
|
||||
|
||||
// A Team's overview page (TEAMS.md §3.1, §3.3, §4.3).
|
||||
//
|
||||
// Three things arrive separately and none of them may take the page down: the
|
||||
// Team itself, its activity feed, and whatever a module renders in the
|
||||
// `team.overview` slot. The Team is the only one this page cannot render without.
|
||||
|
||||
const ACTIVITY_PAGE = 25
|
||||
|
||||
export default function Team() {
|
||||
const { slug } = useParams()
|
||||
const { user } = useAuth()
|
||||
const { loading, error, data } = useAsync(() => api.team(slug), [slug])
|
||||
// Deliberately not awaited alongside the Team: a feed that is slow or failing
|
||||
// must not hold back the counts and the leaders, which are the page's point.
|
||||
const feed = useAsync(() => api.teamActivity(slug, { limit: ACTIVITY_PAGE }), [slug])
|
||||
|
||||
const note = data ? freshnessNote(data) : null
|
||||
const days = groupByDay(feed.data?.items || [])
|
||||
const scopeNote = feed.data ? activityScopeNote(feed.data, Boolean(user)) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load this Team right now." />}
|
||||
|
||||
{!loading && !error && !data && <ErrorState message="No such Team." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={data.abbr ? `[${data.abbr}]` : 'Team'}
|
||||
title={data.name}
|
||||
/>
|
||||
|
||||
{/* An archived Team still resolves, read-only, and says what became of
|
||||
it (§2.2) — an old bookmark or Discord link must land somewhere
|
||||
that explains itself rather than 404ing. */}
|
||||
{data.status === 'archived' && (
|
||||
<p className="panel" style={{ padding: '14px 18px', marginBottom: 18, color: 'var(--muted)' }}>
|
||||
This Team is no longer active.
|
||||
{data.successor && (
|
||||
<>
|
||||
{' '}It is now{' '}
|
||||
<Link to={`/teams/${data.successor.slug}`}>{data.successor.name}</Link>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 6 }}>
|
||||
{rosterSummary({ members: data.memberCount, linked: data.linkedCount })}
|
||||
{data.onlineCount > 0 && ` · ${data.onlineCount} online`}
|
||||
</p>
|
||||
{note && (
|
||||
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.85rem', marginBottom: 18 }}>
|
||||
{note.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style={{ marginBottom: 26 }}>
|
||||
<Link to={`/teams/${slug}/roster`}>View the full roster →</Link>
|
||||
</p>
|
||||
|
||||
{/* The module's spot, under the counts. Core's online number above is
|
||||
the durable floor refreshed at the reconcile interval; a module
|
||||
holding a live presence feed renders the current one here, without
|
||||
core acquiring an SSE stack for it (§3.3). Unfilled on bare core,
|
||||
and a failure inside it is contained to this section. */}
|
||||
<Slot
|
||||
name="team.overview"
|
||||
teamId={data.id}
|
||||
externalId={data.externalId}
|
||||
moduleId={data.moduleId}
|
||||
/>
|
||||
|
||||
<section style={{ marginTop: 30 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.2rem', color: 'var(--head)', marginBottom: 12 }}>
|
||||
Recent activity
|
||||
</h2>
|
||||
|
||||
{feed.loading && <Loading />}
|
||||
{/* A failed feed is a missing SECTION, never a failed page. */}
|
||||
{feed.error && (
|
||||
<p style={{ color: 'var(--muted)' }}>The activity feed could not be loaded.</p>
|
||||
)}
|
||||
|
||||
{!feed.loading && !feed.error && days.length === 0 && (
|
||||
<p style={{ color: 'var(--muted)' }}>Nothing has happened here yet.</p>
|
||||
)}
|
||||
|
||||
{days.map((day) => (
|
||||
<div key={day.key} style={{ marginBottom: 18 }}>
|
||||
<h3 style={{ fontSize: '0.82rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}>
|
||||
{day.label}
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
|
||||
{day.items.map((item) => (
|
||||
<li key={item.id} style={{ color: 'var(--text)', fontSize: '0.95rem' }}>
|
||||
{item.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scopeNote && (
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.85rem', marginTop: 12 }}>{scopeNote}</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import Slot from '../../modules/Slot.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { emptyRosterReason, freshnessNote, rosterSummary } from '../../lib/teams.js'
|
||||
|
||||
// The full roster (TEAMS.md §3.2). `shell="wide"` per §3.1 — this is the one
|
||||
// Team page with a table wide enough to want the room.
|
||||
//
|
||||
// **The link state is a first-class column, not an absence.** A roster where 37
|
||||
// members show but only 21 carry a profile link looks broken until the page says
|
||||
// what the difference is; §3.2 exists because that gap is information (a
|
||||
// character with no site account behind it) and has to read as such.
|
||||
|
||||
export default function TeamRoster() {
|
||||
const { slug } = useParams()
|
||||
const team = useAsync(() => api.team(slug), [slug])
|
||||
const roster = useAsync(() => api.teamRoster(slug), [slug])
|
||||
|
||||
const members = roster.data?.members || []
|
||||
const linked = members.filter((m) => m.linked).length
|
||||
const note = roster.data ? freshnessNote(roster.data) : null
|
||||
const emptyReason = roster.data ? emptyRosterReason(roster.data) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website" shell="wide">
|
||||
<PageHeader
|
||||
eyebrow={team.data ? team.data.name : 'Team'}
|
||||
title="Roster"
|
||||
/>
|
||||
|
||||
<p style={{ marginBottom: 18 }}>
|
||||
<Link to={`/teams/${slug}`}>← Back to the Team</Link>
|
||||
</p>
|
||||
|
||||
{(team.loading || roster.loading) && <Loading />}
|
||||
{roster.error && <ErrorState message="Could not load this roster right now." />}
|
||||
|
||||
{!roster.loading && !roster.error && roster.data && (
|
||||
<>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 6 }}>
|
||||
{rosterSummary({ members: members.length, linked })}
|
||||
</p>
|
||||
{note && (
|
||||
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.85rem', marginBottom: 18 }}>
|
||||
{note.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{emptyReason && <p style={{ color: 'var(--muted)' }}>{emptyReason}</p>}
|
||||
|
||||
{members.length > 0 && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Name</th>
|
||||
<th style={{ padding: '8px 10px' }}>Rank</th>
|
||||
<th style={{ padding: '8px 10px' }}>Status</th>
|
||||
<th style={{ padding: '8px 10px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Keyed by position, unavoidably: the row's stable identifier
|
||||
is its member key and that is exactly what is not published
|
||||
(§3.2). Two characters may share a display name. */}
|
||||
{members.map((member, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={`${member.displayName}-${i}`} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '10px', color: member.linked ? 'var(--head)' : 'var(--muted)' }}>
|
||||
{member.displayName}
|
||||
{member.isLeader && (
|
||||
<span style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.78rem' }}>Leader</span>
|
||||
)}
|
||||
{/* Muted text alone would read as a rendering glitch; the
|
||||
chip is what makes the gap in the header line legible. */}
|
||||
{!member.linked && (
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 8, fontSize: '0.75rem', border: '1px solid var(--line)', borderRadius: 999, padding: '1px 8px' }}>
|
||||
not linked
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px', color: 'var(--muted)' }}>{member.rankLabel || '—'}</td>
|
||||
<td style={{ padding: '10px', color: 'var(--muted)' }}>
|
||||
{member.online ? 'Online' : 'Offline'}
|
||||
</td>
|
||||
{/* The module's trailing cell. Nothing on bare core.
|
||||
§3.4 declares this slot's props as `{ memberKey, userId,
|
||||
displayName }`, and two of those cannot be supplied:
|
||||
§3.2 withholds the member key and the user id from every
|
||||
public roster response, so they are not in the payload
|
||||
this component was rendered from. Publishing them to
|
||||
reach the slot would put a game-internal identifier and
|
||||
a site account id on a public page for every visitor,
|
||||
module installed or not — the doc's two sections
|
||||
contradict each other and §3.2 is the one that is a
|
||||
security rule. The slot gets what core can honestly
|
||||
give it. */}
|
||||
<td style={{ padding: '10px', textAlign: 'right' }}>
|
||||
<Slot
|
||||
name="team.member.row"
|
||||
displayName={member.displayName}
|
||||
isLeader={member.isLeader}
|
||||
linked={member.linked}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { api } from '../../api/client.js'
|
||||
import { filterTeams, freshnessNote, rosterSummary, sortTeams } from '../../lib/teams.js'
|
||||
|
||||
// The Team index (TEAMS.md §3.1). Core's own page: Teams are a core platform
|
||||
// entity and a module only populates them, so this renders on bare core too —
|
||||
// it just has nothing to list, which the empty state says plainly rather than
|
||||
// implying something is broken.
|
||||
|
||||
export default function Teams() {
|
||||
const { loading, error, data } = useAsync(() => api.teams({ limit: 200 }))
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const teams = useMemo(() => filterTeams(sortTeams(data?.teams || []), query), [data, query])
|
||||
const note = data ? freshnessNote(data) : null
|
||||
const total = data?.total || 0
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader eyebrow="Community" title="Teams" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load Teams right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{note && (
|
||||
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.9rem', marginBottom: 18 }}>
|
||||
{note.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{total > 0 && (
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name or abbreviation"
|
||||
aria-label="Search Teams"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
marginBottom: 20,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'rgba(0,0,0,0.25)',
|
||||
color: 'var(--head)',
|
||||
fontFamily: 'var(--sans)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{total === 0 && (
|
||||
<p style={{ color: 'var(--muted)' }}>
|
||||
{data.configured
|
||||
? 'No Teams yet.'
|
||||
: 'This site has no Teams — no installed module supplies them.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{total > 0 && teams.length === 0 && (
|
||||
<p style={{ color: 'var(--muted)' }}>No Team matches “{query}”.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{teams.map((team) => (
|
||||
<Link
|
||||
key={team.slug}
|
||||
to={`/teams/${team.slug}`}
|
||||
className="panel"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '16px 20px',
|
||||
textDecoration: 'none',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{team.name}
|
||||
</strong>
|
||||
{team.abbr && (
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 8, fontSize: '0.9rem' }}>[{team.abbr}]</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
{rosterSummary({ members: team.memberCount, linked: team.linkedCount })}
|
||||
{team.onlineCount > 0 && ` · ${team.onlineCount} online`}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user