feat(frontend): Content packs screen for admins and GMs, with the import affirmation (#553) #563

Merged
claude-bot merged 2 commits from feat/553-content-packs-ui into main 2026-09-08 08:12:07 +00:00
Contributor

Phase 1 of #553, lane 6 of the content-packs spec §10: the one screen, mounted twice.

The screen. ContentPacks.jsx is an Admin → Content packs tab (instance scope, plus the starters list) and a panel on the campaign page for GMs (campaign scope). Upload a file or give a URL; pick the game system; format auto-detected with an override; default visibility; the affirmation text from the server with a required checkbox; progress (polling the pack while it is importing, outside the shared GET cache) and warnings; the list with status, licence, counts, and disable/enable (admins only, as the API is), remove and export. The importer's name comes from a map the parent already holds; an unknown id degrades to the import date rather than a guess.

A fix on the backend side. The affirmation 422 put an object in detail, which broke the project's one error shape ({"detail": "Human-readable message"}) and made client.js show "[object Object]" for exactly the error whose text matters most. It is now {"detail": "<message>", "affirmation": {"version", "text"}}, returned as a JSONResponse from both import paths; docs/API.md and docs/CONTENT-PACKS.md describe the real body and the frontend reads the sibling key. The old shape was never released, so there is no changelog entry for it.

Stacked on #561 (merged). React Compiler rules hold: no state set inside an effect, loading derived, polling driven from the interval callback, keyed inputs so file/URL switching remounts.

Tests: 28 frontend (wire shapes in contentPacks.test.js, behaviour in ContentPacks.test.jsx, mount guards in the page tests); vitest 840 across the suite, eslint clean, vite build green. Backend: 90 in import/admin/notices and 39 in url-fetch/task/export/migration after the 422 change.

🤖 Generated with Claude Code

Phase 1 of #553, lane 6 of the [content-packs spec](docs/design/content-packs-spec.md) §10: the one screen, mounted twice. **The screen.** `ContentPacks.jsx` is an **Admin → Content packs** tab (instance scope, plus the starters list) and a panel on the campaign page for GMs (campaign scope). Upload a file or give a URL; pick the game system; format auto-detected with an override; default visibility; the affirmation text from the server with a required checkbox; progress (polling the pack while it is `importing`, outside the shared GET cache) and warnings; the list with status, licence, counts, and disable/enable (admins only, as the API is), remove and export. The importer's name comes from a map the parent already holds; an unknown id degrades to the import date rather than a guess. **A fix on the backend side.** The affirmation 422 put an object in `detail`, which broke the project's one error shape (`{"detail": "Human-readable message"}`) and made `client.js` show "[object Object]" for exactly the error whose text matters most. It is now `{"detail": "<message>", "affirmation": {"version", "text"}}`, returned as a `JSONResponse` from both import paths; `docs/API.md` and `docs/CONTENT-PACKS.md` describe the real body and the frontend reads the sibling key. The old shape was never released, so there is no changelog entry for it. Stacked on #561 (merged). React Compiler rules hold: no state set inside an effect, loading derived, polling driven from the interval callback, keyed inputs so file/URL switching remounts. Tests: 28 frontend (wire shapes in `contentPacks.test.js`, behaviour in `ContentPacks.test.jsx`, mount guards in the page tests); vitest 840 across the suite, eslint clean, vite build green. Backend: 90 in import/admin/notices and 39 in url-fetch/task/export/migration after the 422 change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The backend for content packs landed with no way to reach it (#553 lanes 1-5).
This is the screen: a **Content packs** tab in Admin for instance-scope packs,
and a GM-only **Content packs** panel on the campaign page for a campaign's own.
One component, `scope` as the difference, because two files for the same screen
would drift the first time the affirmation wording changed.

The affirmation drives most of the design. It is fetched and rendered verbatim
rather than copied into the bundle, so what a person reads is provably the text
the server hashes; its version travels with every import, including a starter
pack; the box is unticked again after each success, because affirming is per
import and not per visit; and a 422 — the wording changed while the page was
open — re-arms the box with the new text out of the refusal body rather than
asking someone to reload and guess.

Two backend shapes worth naming. That 422's `detail` is an object, so the
shared client's `err.message` is "[object Object]" and the text has to come out
of `err.body.detail` (`affirmationFromError`). And there is no GM-scoped
disable/enable, deliberately — disabling is how a rightsholder's notice gets
answered, so those two buttons are gated on `isAdmin` on both scopes, while
remove and export stay with whoever owns the pack.

An importing pack polls `getPack` every 3 s with an elapsed clock on it (#381);
`getPack` opts out of the client's 15 s GET cache or four polls in five would
read the same stale answer. Export is a link, not a fetch: the response is a zip
whose filename is in a header.

`imported_by` comes back as a bare uuid, so both mount points pass down the
names they already hold — Admin's user list, the campaign's members — rather
than the screen making a second request for something the page above has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(webapp): the affirmation 422 keeps Quest Board's one error shape (#553)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 39s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m27s
CI / Bot/backend version sync (pull_request) Successful in 37s
CI / Bot tests and audit (pull_request) Successful in 1m58s
CI / Docker image build (pull_request) Successful in 1m2s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m1s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m47s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 18m54s
d6e120f602
Every error body in this project is `{"detail": "<human-readable message>"}`
(CLAUDE.md, "Error response format"), and the web client leans on it: the shared
fetch wrapper puts `detail` straight into an `Error`'s message. The content-pack
foundation put an object there instead — `{message, affirmation_version,
affirmation_text}` — so the one refusal a person most needs to read rendered as
"[object Object]" in any client that did the ordinary thing with `err.message`.

The body is now:

    {"detail": "<the message>",
     "affirmation": {"version": "2026-09", "text": "<the wording>"}}

`AffirmationRequired.detail` is the message string, so any future raiser can
hand it to `HTTPException` and still be conformant; `AffirmationRequired.payload`
assembles the whole body, in one place, so the two router call sites cannot
drift. Those two sites return a `JSONResponse` with the 422 rather than raising,
because `HTTPException` can only put things inside `detail` — which is the
problem. Nothing else about the refusal changes: still 422, still nothing
fetched, parsed or written before the affirmation passes.

Client-side, `affirmationFromError` reads `err.body.affirmation` for the wording
and `err.message` for the sentence, which is now a sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-08 07:50:39 +00:00
claude-bot deleted branch feat/553-content-packs-ui 2026-09-08 08:12:08 +00:00
Sign in to join this conversation.
No description provided.