Merge pull request 'docs(website): theming & nav phases 0-2 as built' (#104) from docs/theming-nav-phase-0-2 into edge
Reviewed-on: #104
This commit is contained in:
@@ -119,6 +119,17 @@ server/
|
|||||||
vendors, chars, sales, houses
|
vendors, chars, sales, houses
|
||||||
appeals.router.js (4) /player/appeals
|
appeals.router.js (4) /player/appeals
|
||||||
shard.controller.js + appeals.controller.js
|
shard.controller.js + appeals.controller.js
|
||||||
|
settings/ index.js owns the shared `noindex, requireAuth` gate
|
||||||
|
(authenticated, ANY role) and the mount table.
|
||||||
|
A fifth group, for site-wide settings that
|
||||||
|
need a login but no particular role — /public
|
||||||
|
is anonymous, /admin/settings is adminOnly
|
||||||
|
while AdminLayout renders for editors and
|
||||||
|
moderators, and /player is self-scoped data
|
||||||
|
nav.router.js (1) /settings/nav — the nav_admin and
|
||||||
|
nav_player overrides, read by the
|
||||||
|
layouts that render them
|
||||||
|
nav.controller.js
|
||||||
admin/ index.js mounts the capability routers below at their
|
admin/ index.js mounts the capability routers below at their
|
||||||
own prefixes; owns the shared
|
own prefixes; owns the shared
|
||||||
`noindex, isLoggedIn, staffOnly` gate and
|
`noindex, isLoggedIn, staffOnly` gate and
|
||||||
@@ -151,7 +162,12 @@ server/
|
|||||||
email.router.js (6) /admin/email — Gmail OAuth2
|
email.router.js (6) /admin/email — Gmail OAuth2
|
||||||
delivery — adminOnly
|
delivery — adminOnly
|
||||||
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
||||||
settings.router.js (2) /admin/settings — adminOnly
|
settings.router.js (3) /admin/settings — adminOnly. The
|
||||||
|
DELETE /:key is "reset to default"
|
||||||
|
and carries its own key allowlist
|
||||||
|
(theming/nav keys + the hero draft)
|
||||||
|
so it can never drop site_mode or
|
||||||
|
the uo-link config
|
||||||
dashboard.router.js (2) GET /dashboard (staff-wide) and
|
dashboard.router.js (2) GET /dashboard (staff-wide) and
|
||||||
PUT /site-mode (adminOnly) — the
|
PUT /site-mode (adminOnly) — the
|
||||||
two singletons owning no path
|
two singletons owning no path
|
||||||
@@ -248,6 +264,21 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
|||||||
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
||||||
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
||||||
|
|
||||||
|
**Deliberately unseeded keys** — the theming & navigation overrides
|
||||||
|
(`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`). All
|
||||||
|
five are JSON strings, and **the absence of the row is the "use the default"
|
||||||
|
state**: colors/fonts/radii fall back to `theme.css`, assets to `BRAND_*`, navs
|
||||||
|
to the hardcoded `NAV` arrays. No migration writes defaults into them, because a
|
||||||
|
stored copy of a default would stop tracking the default. Resetting one is
|
||||||
|
therefore a `DELETE`, not a write — see `DELETABLE_KEYS` in `settings.model.js`
|
||||||
|
and [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §2.
|
||||||
|
|
||||||
|
Values are `TEXT`, so a JSON-valued key arrives as a **string** and every
|
||||||
|
consumer parses it. Server side that is `utils/settingsJson.js`
|
||||||
|
(`parseJsonSetting`), client side `parseLayout`/its callers; both treat a
|
||||||
|
malformed or wrong-shaped value as **absent** rather than as an error, so a
|
||||||
|
hand-edited row degrades to the default instead of rendering something broken.
|
||||||
|
|
||||||
### activity_log — append-only
|
### activity_log — append-only
|
||||||
| col | type | notes |
|
| col | type | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -602,7 +633,7 @@ are authoritative, and they answer different questions:
|
|||||||
|
|
||||||
| Artifact | Source of truth for | Generated by |
|
| Artifact | Source of truth for | Generated by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 215 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 225 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||||
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||||
|
|
||||||
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
|
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
|
||||||
@@ -802,6 +833,17 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
|
|
||||||
Public content GETs pass through the **siteMode** gate (§5).
|
Public content GETs pass through the **siteMode** gate (§5).
|
||||||
|
|
||||||
|
### /settings (settings/index.js → §2) — behind `requireAuth` + `noindex`, no role gate
|
||||||
|
|
||||||
|
Site-wide settings that need a login but no particular role. It exists because the
|
||||||
|
other four groups each answer a different question: `/public` is anonymous,
|
||||||
|
`/admin/settings` is `adminOnly`, and `/player` is data scoped to `req.user.id`.
|
||||||
|
These rows are configuration that happens to need a login.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/settings/nav` | `{ nav_admin, nav_player }` — the stored nav overrides as raw JSON strings (or `null`), for the two authenticated layouts that render them. Deliberately not public: an anonymous visitor has no use for either, and the admin nav's labels describe the shape of the admin surface. Open to **any** role because `AdminLayout` renders for editors and moderators and `PlayerPortalLayout` for players, none of whom can read `GET /admin/settings`. Presentation-only — the role/feature filters in those layouts still decide what is shown, and an override can never un-hide a gated item (see [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §7) |
|
||||||
|
|
||||||
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
|
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
|
||||||
|
|
||||||
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
|
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
|
||||||
@@ -837,6 +879,7 @@ file a route sits in — that is the property the route manifest freezes.
|
|||||||
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
||||||
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
||||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
||||||
|
| DELETE | `/settings/:key` | reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`, `hero_layout_draft`) — anything else is `400`. Idempotent: resetting a key that was never set succeeds |
|
||||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||||
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
||||||
|
|||||||
@@ -479,6 +479,31 @@ function applyNavOverrides(baseNav, overrides) {
|
|||||||
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
|
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
|
||||||
path and is the single most important case to test.
|
path and is the single most important case to test.
|
||||||
|
|
||||||
|
**As built (Phase 1).** Two shapes are handled by the one function — flat
|
||||||
|
(`SiteHeader`, `PlayerPortalLayout`) and grouped (`AdminLayout`) — detected by
|
||||||
|
whether every entry carries an `items` array. Three rules the doc left open,
|
||||||
|
settled by the implementation and locked by tests:
|
||||||
|
|
||||||
|
- **Ordering.** An item the admin never reordered keeps its index in the base
|
||||||
|
array as its sort key, so setting one `order` does not scramble the rest.
|
||||||
|
Explicit and implicit keys therefore share one number line and can collide;
|
||||||
|
ties break **explicit first** (an admin who said "0" means first, not
|
||||||
|
"wherever the untouched item at index 0 already sits"), and two explicit
|
||||||
|
equal orders keep code order via a stable sort. The editor writes an order for
|
||||||
|
every item in a list the way drag-and-drop does, so ties are the stale-row
|
||||||
|
case, not the normal one — they just have to resolve predictably.
|
||||||
|
- **`group`.** Accepted only when it names a title the base nav already
|
||||||
|
declares; anything else is dropped, so an item can never land under a header
|
||||||
|
that does not exist. Group *order* is not overridable — sections stay in code
|
||||||
|
order, only membership and within-group order move.
|
||||||
|
- **Field-by-field validation.** A bad `label` does not discard a good `order`
|
||||||
|
beside it, and `hidden` is honored only as the literal boolean `true`.
|
||||||
|
Everything unrecognized is ignored rather than rejected, so a hand-edited row
|
||||||
|
degrades to the code default instead of rendering a broken nav.
|
||||||
|
|
||||||
|
`hidden: false` cannot un-hide anything: hiding here is subtractive only, and
|
||||||
|
the role/feature filters still run afterward, unchanged.
|
||||||
|
|
||||||
## 8. Build phases
|
## 8. Build phases
|
||||||
|
|
||||||
Each phase is independently shippable and leaves the site rendering identically to
|
Each phase is independently shippable and leaves the site rendering identically to
|
||||||
@@ -486,9 +511,9 @@ today until the admin acts.
|
|||||||
|
|
||||||
| Phase | Work |
|
| Phase | Work |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **0 — Settings-store groundwork** | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
|
| **0 — Settings-store groundwork** ✅ | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
|
||||||
| **1 — `navOverrides.js` + tests** | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
|
| **1 — `navOverrides.js` + tests** ✅ | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
|
||||||
| **2 — Radius/shadow token groundwork** | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
| **2 — Radius/shadow token groundwork** ✅ | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
||||||
| **3 — Theme engine** | Three preset blocks, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
| **3 — Theme engine** | Three preset blocks, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
||||||
| **4 — Admin theme UI** | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
| **4 — Admin theme UI** | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
||||||
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
|
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
|
||||||
@@ -498,7 +523,36 @@ today until the admin acts.
|
|||||||
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
|
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
|
||||||
|
|
||||||
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
||||||
fourth.
|
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
|
||||||
|
`main` as one `edge` → `main` merge once every phase is in, so no release ever
|
||||||
|
carries a half-wired theme engine.
|
||||||
|
|
||||||
|
### Phases 0–2 as landed
|
||||||
|
|
||||||
|
- **`/api/v1/settings` is a fifth router group**, not a route bolted onto an
|
||||||
|
existing one. §4.2 named the URL but not where it lives, and the domain split
|
||||||
|
leaves no group it fits: `/public` is anonymous, `/admin/settings` is
|
||||||
|
`adminOnly` while `AdminLayout` renders for editors and moderators, and
|
||||||
|
`/player` is data scoped to `req.user.id`. The group carries
|
||||||
|
`noindex, requireAuth` and no role gate. The route-manifest guard test that
|
||||||
|
asserts every `/admin/**` and `/player/**` route sits behind `requireAuth` now
|
||||||
|
covers `/settings/**` too.
|
||||||
|
- **Reset is `DELETE /api/v1/admin/settings/:key`** with the allowlist in
|
||||||
|
`settings.model.js` (`DELETABLE_KEYS`), which is what stops a stray request
|
||||||
|
from dropping `site_mode` or the uo-link config. It is admin-only and
|
||||||
|
idempotent, and a test asserts it never writes a row.
|
||||||
|
- **`parseJsonSetting` lives at `server/src/utils/settingsJson.js`.** Non-object
|
||||||
|
JSON (`4`, `"x"`, `null`, `[]`) is treated as absent alongside syntax errors,
|
||||||
|
and a validator rejection discards the whole object rather than half-applying
|
||||||
|
it. The client keeps `parseLayout`; a client-side counterpart arrives with its
|
||||||
|
first consumer in Phase 3.
|
||||||
|
- **Phase 2 was a 23-declaration promotion** — 14×`8px` → `--radius-input`,
|
||||||
|
4×`999px` → `--radius-pill`, 4×`10px` → `--radius-card`, 1×`12px` →
|
||||||
|
`--radius-panel` — matching the §4.7 census exactly. The `7px`/`6px` editor
|
||||||
|
chrome and the two `50%` circles stay literal. `--shadow-card` and
|
||||||
|
`--panel-grad` were **already** tokens and already derived, so the shadow half
|
||||||
|
of the phase was a no-op; the only two `box-shadow` declarations in
|
||||||
|
`theme.css` both already read `var(--shadow-card)`.
|
||||||
|
|
||||||
### 8.1 Admin builder UI notes
|
### 8.1 Admin builder UI notes
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,10 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/settings"
|
"path": "/api/v1/admin/settings"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/settings/:key"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/shard/account"
|
"path": "/api/v1/admin/shard/account"
|
||||||
@@ -293,6 +297,18 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/char/:serial"
|
"path": "/api/v1/admin/shard/char/:serial"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/import"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/path"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/houses"
|
"path": "/api/v1/admin/shard/houses"
|
||||||
@@ -817,10 +833,30 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/idoc"
|
"path": "/api/v1/public/shard/idoc"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/meta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/vendors/:serial"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/online"
|
"path": "/api/v1/public/shard/online"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points/:system"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/presence"
|
"path": "/api/v1/public/shard/presence"
|
||||||
@@ -860,6 +896,10 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/wiki/tags"
|
"path": "/api/v1/public/wiki/tags"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/settings/nav"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"internal": [
|
"internal": [
|
||||||
|
|||||||
Reference in New Issue
Block a user