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:
@@ -18,6 +18,9 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
|||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.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 Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.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 PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||||
|
import PlayerTeams from './routes/player/PlayerTeams.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +97,13 @@ export default function App() {
|
|||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<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
|
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
||||||
registry prefixes the segment, so a module cannot spell its way
|
registry prefixes the segment, so a module cannot spell its way
|
||||||
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
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="/player" element={<PlayerIndex />} />
|
||||||
<Route path="/account" element={<PlayerAccount />} />
|
<Route path="/account" element={<PlayerAccount />} />
|
||||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
<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
|
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||||
group's own routes are absolute (its layout route has no path),
|
group's own routes are absolute (its layout route has no path),
|
||||||
so the prefix is written here rather than inherited — the one
|
so the prefix is written here rather than inherited — the one
|
||||||
|
|||||||
@@ -133,6 +133,29 @@ export const api = {
|
|||||||
return req(`/public/wiki${withQs(s)}`)
|
return req(`/public/wiki${withQs(s)}`)
|
||||||
},
|
},
|
||||||
wikiCategories: () => req('/public/wiki/categories'),
|
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'),
|
wikiTags: () => req('/public/wiki/tags'),
|
||||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import { useFeatureGate } from '../modules/features.jsx'
|
|||||||
export const NAV = [
|
export const NAV = [
|
||||||
{ label: 'Home', to: '/', end: true },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ 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: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
|
|||||||
151
client/src/lib/teams.js
Normal file
151
client/src/lib/teams.js
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import { publishSharedDependencies } from './modules/shared.js'
|
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'
|
import './styles/theme.css'
|
||||||
|
|
||||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
// 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
|
// 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
|
// 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.
|
// 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) ───────────────────────────────────
|
// ── 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,
|
// 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.
|
// which is what core's own code did whenever the flag was off.
|
||||||
declareSlot('player.invite.accepted')
|
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
|
// 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
|
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
|
||||||
|
|||||||
56
client/src/modules/coreFeatures.js
Normal file
56
client/src/modules/coreFeatures.js
Normal 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
|
||||||
@@ -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 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 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;
|
// 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
|
// 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,
|
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||||
// with `order: 0`.
|
// with `order: 0`.
|
||||||
export const NAV = [
|
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/appeals', label: 'Appeals', icon: IconShield },
|
||||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||||
]
|
]
|
||||||
@@ -56,6 +63,7 @@ export const NAV = [
|
|||||||
const TITLES = {
|
const TITLES = {
|
||||||
'/account': 'Account',
|
'/account': 'Account',
|
||||||
'/account/appeals': 'Appeals',
|
'/account/appeals': 'Appeals',
|
||||||
|
'/player/teams': 'My Teams',
|
||||||
}
|
}
|
||||||
|
|
||||||
function moduleTitle(baseNav, pathname) {
|
function moduleTitle(baseNav, pathname) {
|
||||||
|
|||||||
67
client/src/routes/player/PlayerTeams.jsx
Normal file
67
client/src/routes/player/PlayerTeams.jsx
Normal 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 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
client/src/routes/public/Team.jsx
Normal file
126
client/src/routes/public/Team.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
client/src/routes/public/TeamRoster.jsx
Normal file
120
client/src/routes/public/TeamRoster.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
client/src/routes/public/Teams.jsx
Normal file
107
client/src/routes/public/Teams.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
client/test/teams.test.js
Normal file
162
client/test/teams.test.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// What the public Team pages say (docs/website/TEAMS.md §3.2, §3.3, §4.3).
|
||||||
|
//
|
||||||
|
// lib/teams.js is plain JS precisely so these can be asserted without a DOM. The
|
||||||
|
// cases worth protecting are the ones where a wrong sentence is a false statement
|
||||||
|
// about the game rather than a cosmetic slip — an empty roster reported as "no
|
||||||
|
// members" when the module could not be asked being the clearest.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
LINK_STATE,
|
||||||
|
activityScopeNote,
|
||||||
|
emptyRosterReason,
|
||||||
|
filterTeams,
|
||||||
|
freshnessNote,
|
||||||
|
groupByDay,
|
||||||
|
linkStateOf,
|
||||||
|
relativeTime,
|
||||||
|
rosterSummary,
|
||||||
|
sortTeams,
|
||||||
|
} from '../src/lib/teams.js'
|
||||||
|
|
||||||
|
// ── The roster header ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the header states what each number is, so the gap reads as information', () => {
|
||||||
|
assert.equal(rosterSummary({ members: 37, linked: 21 }), '37 members · 21 linked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one member is not "1 members"', () => {
|
||||||
|
assert.equal(rosterSummary({ members: 1, linked: 1 }), '1 member · 1 linked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forum guests appear only once there are some', () => {
|
||||||
|
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 0 }), '37 members · 21 linked')
|
||||||
|
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 4 }), '37 members · 21 linked · 4 forum guests')
|
||||||
|
assert.equal(rosterSummary({ members: 2, linked: 1, guests: 1 }), '2 members · 1 linked · 1 forum guest')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an absent roster still produces a sentence rather than NaN', () => {
|
||||||
|
assert.equal(rosterSummary(), '0 members · 0 linked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('link state is a value, not an absence', () => {
|
||||||
|
assert.equal(linkStateOf({ linked: true }), LINK_STATE.linked)
|
||||||
|
assert.equal(linkStateOf({ linked: false }), LINK_STATE.unlinked)
|
||||||
|
assert.equal(linkStateOf(undefined), LINK_STATE.unlinked)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Freshness ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
|
||||||
|
const ago = (ms) => new Date(NOW - ms).toISOString()
|
||||||
|
|
||||||
|
test('a deployment with no provider is not stale, it is uninvolved', () => {
|
||||||
|
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
||||||
|
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
||||||
|
assert.equal(note.tone, 'warn')
|
||||||
|
assert.match(note.text, /Not yet confirmed/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stale projection says how old it is and that the game may have moved on', () => {
|
||||||
|
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
|
||||||
|
assert.equal(note.tone, 'warn')
|
||||||
|
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a current projection is stated quietly', () => {
|
||||||
|
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
|
||||||
|
assert.equal(note.tone, 'idle')
|
||||||
|
assert.equal(note.text, 'Last confirmed 1 minute ago.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('relative time singularises and steps through the units', () => {
|
||||||
|
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
|
||||||
|
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
|
||||||
|
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
|
||||||
|
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
|
||||||
|
assert.equal(relativeTime(null, NOW), null)
|
||||||
|
assert.equal(relativeTime('not a date', NOW), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Why a roster is empty ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a populated roster has no explaining to do', () => {
|
||||||
|
assert.equal(emptyRosterReason({ members: [{}] }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module that could not be asked is never reported as an empty guild', () => {
|
||||||
|
// The failure this function exists to prevent: saying something false about
|
||||||
|
// the game because core could not reach the module.
|
||||||
|
const reason = emptyRosterReason({ members: [], projectionUnavailable: true })
|
||||||
|
assert.match(reason, /could not be reached/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unconfirmed projection says so rather than claiming the Team is empty', () => {
|
||||||
|
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: null })
|
||||||
|
assert.match(reason, /not been confirmed/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a genuinely empty, confirmed roster says the plain thing', () => {
|
||||||
|
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: ago(1000) })
|
||||||
|
assert.equal(reason, 'Nobody is in this Team.')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The activity feed ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('items group into days, newest day first, order kept within a day', () => {
|
||||||
|
const days = groupByDay([
|
||||||
|
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
|
||||||
|
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
|
||||||
|
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
|
||||||
|
], 'en-US')
|
||||||
|
assert.equal(days.length, 2)
|
||||||
|
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
|
||||||
|
assert.deepEqual(days[1].items.map((i) => i.id), [1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
|
||||||
|
const days = groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US')
|
||||||
|
assert.deepEqual(days, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a caller who saw everything is told nothing', () => {
|
||||||
|
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
|
||||||
|
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
|
||||||
|
assert.match(activityScopeNote({ scope: 'public' }, true), /members of this Team only/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The index ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('teams sort by size then by name', () => {
|
||||||
|
const sorted = sortTeams([
|
||||||
|
{ name: 'Zephyr', memberCount: 3 },
|
||||||
|
{ name: 'Anvil', memberCount: 10 },
|
||||||
|
{ name: 'Bell', memberCount: 3 },
|
||||||
|
])
|
||||||
|
assert.deepEqual(sorted.map((t) => t.name), ['Anvil', 'Bell', 'Zephyr'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sorting does not mutate its input', () => {
|
||||||
|
const input = [{ name: 'B', memberCount: 1 }, { name: 'A', memberCount: 9 }]
|
||||||
|
sortTeams(input)
|
||||||
|
assert.equal(input[0].name, 'B')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('search matches the two things a visitor knows a Team by', () => {
|
||||||
|
const teams = [{ name: 'The Silver Hand', abbr: 'TSH' }, { name: 'Anvil', abbr: 'ANV' }]
|
||||||
|
assert.deepEqual(filterTeams(teams, 'silver').map((t) => t.abbr), ['TSH'])
|
||||||
|
assert.deepEqual(filterTeams(teams, 'anv').map((t) => t.abbr), ['ANV'])
|
||||||
|
assert.equal(filterTeams(teams, ' ').length, 2)
|
||||||
|
assert.equal(filterTeams(teams, 'nothing').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('search survives a team with no abbreviation', () => {
|
||||||
|
assert.doesNotThrow(() => filterTeams([{ name: 'Anvil', abbr: null }], 'a'))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user