feat(teams): the public Team pages, the two slots and the nav flag

TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full
roster and the player portal's "My Teams" — plus the two extension slots a
module adds to them, and the nav rows that lead there.

These are CORE routes, not module ones. A Team is a core platform entity that a
module merely populates, so the whole experience renders on bare core; a module
adds to these pages rather than supplying them.

`team.member.row` is declared with `{ displayName, isLeader, linked }` and not
§3.4's `{ memberKey, userId, displayName }`. The two documents contradict each
other and §3.2 is the one that is a security rule: a slot component runs in the
browser, so those props can only reach it by publishing a game-internal
identifier and a site account id in every public roster response, for every
visitor, module installed or not. Recorded as an amendment.

The presentation logic is split into lib/teams.js with its own tests, following
lib/teamAdmin.js, because these pages have to state differences that read as
bugs unless they are worded deliberately:

  - "37 members · 21 linked" — the gap is information (a character with no site
    account behind it), and the header says what each number IS rather than
    showing both and hoping;
  - an empty roster has three unrelated causes — nobody in the Team, a rung
    that shows nobody, and a module that could not be asked — and reporting the
    last as the first is a statement about the game that happens to be false;
  - a stale projection says how old it is rather than presenting itself as
    current.

`teams` is the first CORE nav row to carry a `feature` since the shard rows left
with the module cutover, and it brings core's own feature provider back with it.
It gates on whether this deployment has Teams AT ALL, not on who is looking —
Team pages are public and the server gates them. It fails open, so an unknown
answer shows the link: a Teams link leading somewhere empty is a far cheaper
mistake than a Team page nobody can find.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:15:54 -05:00
parent 03631d7d40
commit 8f4aff6946
12 changed files with 861 additions and 3 deletions

View File

@@ -18,6 +18,9 @@ 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'
@@ -57,6 +60,7 @@ 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 (
@@ -93,6 +97,13 @@ 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
@@ -216,6 +227,11 @@ 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

View File

@@ -133,6 +133,29 @@ export const api = {
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
// ----- Teams (TEAMS.md §2.11, §3.1) -----
//
// 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`),
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

View File

@@ -28,6 +28,11 @@ 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' },

151
client/src/lib/teams.js Normal file
View File

@@ -0,0 +1,151 @@
// 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),
)
}

View File

@@ -3,7 +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 } from './modules/registry.js'
import { declareSlot, registerFeatureProvider } from './modules/registry.js'
import { useCoreFlags } from './modules/coreFeatures.js'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -18,8 +19,12 @@ 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.
// There is nothing for core to register now — no core nav row carries a
// `feature` — and the filter is a correct no-op until a module supplies one.
//
// 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) ───────────────────────────────────
//
@@ -50,6 +55,18 @@ 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

View File

@@ -0,0 +1,56 @@
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

View File

@@ -35,6 +35,7 @@ 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
@@ -46,6 +47,12 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
// 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 },
]
@@ -56,6 +63,7 @@ export const NAV = [
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/player/teams': 'My Teams',
}
function moduleTitle(baseNav, pathname) {

View File

@@ -0,0 +1,67 @@
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 Teams 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>
</>
)
}

View File

@@ -0,0 +1,126 @@
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>
)
}

View File

@@ -0,0 +1,120 @@
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>
)
}

View File

@@ -0,0 +1,107 @@
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>
)
}