feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -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