diff --git a/client/src/App.jsx b/client/src/App.jsx index 1750361..6353cc5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,9 @@ import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' +import Teams from './routes/public/Teams.jsx' +import Team from './routes/public/Team.jsx' +import TeamRoster from './routes/public/TeamRoster.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -57,6 +60,7 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' +import PlayerTeams from './routes/player/PlayerTeams.jsx' export default function App() { return ( @@ -93,6 +97,13 @@ export default function App() { } /> } /> } /> + {/* 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. */} + } /> + } /> + } /> {/* Installed modules' public pages, namespaced `//…` — the registry prefixes the segment, so a module cannot spell its way out of it (docs/website/MODULE_API.md §3.3). Declared before the @@ -216,6 +227,11 @@ export default function App() { } /> } /> } /> + {/* 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. */} + } /> {/* Installed modules' player-portal pages, at /player//…. This group's own routes are absolute (its layout route has no path), so the prefix is written here rather than inherited — the one diff --git a/client/src/api/client.js b/client/src/api/client.js index 64359c4..bd19f4d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -133,6 +133,29 @@ export const api = { return req(`/public/wiki${withQs(s)}`) }, wikiCategories: () => req('/public/wiki/categories'), + + // ----- Teams (TEAMS.md §2.11, §3.1) ----- + // + // Public reads, but three of them behave differently for a signed-in caller and + // the session rides along on the cookie the shared `req` already sends: the + // roster may widen with the module's audience projection, and the activity feed + // adds members-only items. None of them REQUIRES a session. + teams: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams${withQs(qs.toString())}`) + }, + team: (slug) => req(`/public/teams/${encodeURIComponent(slug)}`), + teamRoster: (slug) => req(`/public/teams/${encodeURIComponent(slug)}/members`), + teamActivity: (slug, opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) + }, + myTeams: () => req('/player/teams'), + myTeamAccess: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/access`), wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 84706f0..064536d 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -28,6 +28,11 @@ import { useFeatureGate } from '../modules/features.jsx' export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, + // The first CORE row to carry a `feature` since the shard rows left in slice 3 + // (TEAMS.md §3.5). It is answered by core's own provider (main.jsx) and gates + // on whether this deployment has Teams at all, not on who is looking — Team + // pages are public. Fails open, so an unknown answer shows the link. + { label: 'Teams', to: '/teams', feature: 'teams' }, { label: 'Screenshots', to: '/site/screenshots' }, { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, diff --git a/client/src/lib/teams.js b/client/src/lib/teams.js new file mode 100644 index 0000000..e73a208 --- /dev/null +++ b/client/src/lib/teams.js @@ -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), + ) +} diff --git a/client/src/main.jsx b/client/src/main.jsx index c2c3101..8ab9ff7 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,7 +3,8 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { declareSlot } from './modules/registry.js' +import { declareSlot, registerFeatureProvider } from './modules/registry.js' +import { useCoreFlags } from './modules/coreFeatures.js' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -18,8 +19,12 @@ publishSharedDependencies() // and namespace `uo`, so that the seam was exercised by real content from the // day it was built. That prediction paid out exactly as written: the extraction // deleted the registration and the hook it named, and SiteHeader was not touched. -// There is nothing for core to register now — no core nav row carries a -// `feature` — and the filter is a correct no-op until a module supplies one. +// +// Teams put a core row back on the seam. `feature: 'teams'` on the three Team nav +// rows resolves against owner id `core` (featureGate.js: a row with no `moduleId` +// belongs to core), and this is the provider that answers it — hiding the rows on +// a deployment that has no Teams at all, and failing open everywhere else. +registerFeatureProvider('core', 'core', useCoreFlags) // ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── // @@ -50,6 +55,18 @@ declareSlot('admin.users.detail') // over `onDone`. With the slot unfilled the invitee goes straight to the portal, // which is what core's own code did whenever the flag was off. declareSlot('player.invite.accepted') +// The two Team slots (TEAMS.md §3.4, MODULE_API 1.6.0). Both named for a place: +// `team.overview` is the spot under the counts on a Team page, not "where the +// game puts guild stats", and `team.member.row` is the trailing cell of a roster +// row. Core renders the whole Team experience with both unfilled — the pages are +// core's, and a module adds to them rather than supplying them. +// +// `team.overview` is where a live "online now" strip belongs: core's online count +// is the durable floor refreshed at the reconcile interval (§3.3), and a module +// that already holds a live presence feed can render the current number here +// without core acquiring an SSE stack to do it. +declareSlot('team.overview') +declareSlot('team.member.row') // Core filled the first two itself until slice 3, with the components that were // inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills diff --git a/client/src/modules/coreFeatures.js b/client/src/modules/coreFeatures.js new file mode 100644 index 0000000..c797157 --- /dev/null +++ b/client/src/modules/coreFeatures.js @@ -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 diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index 12e9153..cd1c537 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) { } const IconGear = () => const IconShield = () => +const IconTeams = () => // Exported because Admin -> Navigation edits this list. It stays declared here; // the editor may only relabel, reorder and hide what it finds (§7). No CORE row @@ -46,6 +47,12 @@ const IconShield = () =>