Decomposes the admin SPA into milestones; specs M1 — web/ scaffold, Vite+React+TS+pnpm+shadcn/ui, openapi-typescript+openapi-fetch typed client, TanStack Query, react-i18next (sv/en), two-pane master-detail layout, login/session guard, read-only Objects browse, Vitest+RTL+MSW tests, memory-serve embed behind a feature gate, 150KB bundle budget. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.5 KiB
Frontend SPA — Milestone 1 (Foundation Slice) — Design
Date: 2026-06-03 Status: Approved (brainstorming) — ready for implementation planning.
Context
The backend MVP is code-complete: an authenticated admin HTTP API (object CRUD +
flexible fields, vocabulary/authority/term management, stepwise publishing, search,
audit), a public read API, and a code-first OpenAPI document (utoipa). There is no
frontend yet. VISION + docs/specs/2026-06-02-mvp-architecture.md §17 call for a
lean React SPA in web/ that consumes the OpenAPI, with sv/en localization and an
explicit "potato hardware" bundle-discipline budget.
The admin UI is a large surface, so it is decomposed into milestones, each with its own spec → plan → implementation cycle:
- Foundation slice (this spec): scaffold, build/dev/serve, typed API client, app shell (nav + i18n), login/session, and the Objects screen (paginated list + read-only detail).
- Object authoring — create/edit/delete + the dynamic flexible-field form driven by
field-definitions. - Publishing workflow — stepwise draft→internal→public UI with the required-field gate.
- Vocabulary & authority management.
- Search UI.
Goal (Milestone 1)
A walking skeleton that proves the whole pipeline end-to-end — log in → fetch → render → embedded in the binary — and lands a real, usable screen (browse + read catalogue objects). Every piece of plumbing here is reused by later milestones.
Decisions (settled during brainstorming)
- Stack: React + TypeScript + Vite, pnpm, shadcn/ui components, TanStack Query for server state, react-i18next for sv/en.
- API client (Option A):
openapi-typescriptgenerates types from the OpenAPI JSON;openapi-fetch(tiny typed wrapper) for calls; TanStack Query hooks written by hand. Smallest dependency surface, contract auto-synced, query layer under our control. - Layout (Option C): two-pane master–detail — object list on the left, the selected record fills the right pane (inspector style; efficient for browse + later edit-in-place).
- Testing (Option A): Vitest + React Testing Library + MSW (Mock Service Worker)
— component/hook tests in-process; MSW intercepts fetches with handlers typed against
the generated schema, so tests stay honest to the OpenAPI contract without a browser.
A Playwright e2e smoke is deferred to a later milestone (fits once
--seeddata exists). - Bundle budget: initial JS ≤ 150 KB gzipped, tracked in CI.
Scope (YAGNI)
In:
web/scaffold + build/dev/serve (incl. release embedding).- Generated typed API client + TanStack Query hooks.
- App shell: compact icon sidebar (Objects active; later items shown disabled / "coming soon"), top bar with sv/en switch + user menu (logout).
- Auth: login page, logout, session guard.
- Objects two-pane: paginated list (left) + read-only detail (right) showing the inventory-minimum fields and flexible-field values, with a visibility badge.
Out (later milestones): create/edit/delete and dynamic field forms (M2), publish workflow (M3), vocabulary/authority/term management (M4), search UI (M5), media/ thumbnails. Later nav items appear as disabled stubs so the shell's shape is visible.
Architecture
Project layout (web/)
web/
package.json pnpm; scripts: dev, build, test, typecheck, lint, gen:api
vite.config.ts dev proxy /api,/api-docs,/health -> http://localhost:8080
tsconfig.json
index.html
components.json shadcn/ui
src/
main.tsx entry: QueryClientProvider, i18n, router
app.tsx route table
api/
schema.d.ts generated (openapi-typescript) — committed
client.ts openapi-fetch client; credentials:'include'; 401 middleware
queries.ts useMe, useObjectsPage, useObject, useLogin, useLogout
auth/
session.tsx session context (via /api/admin/me); RequireAuth guard
login-page.tsx
shell/
app-shell.tsx icon sidebar + top bar + <Outlet/>
lang-switch.tsx
objects/
objects-page.tsx two-pane container (list + detail by :id)
object-list.tsx
object-detail.tsx
visibility-badge.tsx
i18n/
index.ts react-i18next init
en.json sv.json
components/ui/... shadcn primitives
test/
setup.ts Vitest + RTL + MSW server
handlers.ts MSW handlers typed against schema.d.ts
Serve / build model
- Dev:
pnpm devruns Vite on:5173, proxying/api,/api-docs,/healthto the Rust server on:8080(run separately). Session cookies stay same-origin through the proxy. Backend dev/test loop is unchanged and independent of the frontend. - Release:
pnpm build→web/dist, embedded into theserverbinary via thememory-servecrate and served at/with an SPA fallback (any non-/api, non-/health, non-/api-docspath →index.htmlfor client-side routing). - Feature gate
embed-web(off by default) on theservercrate guards the memory-serve embedding, socargo build/cargo testnever require a builtweb/dist. CI builds the SPA first, thencargo build -p server --features embed-webfor the release artifact. Milestone 1 proves this embed path end-to-end.
Data flow
- App mounts →
useMe()queries/api/admin/me. 200 → session established; 401 → the client middleware redirects to/login. /objects→useObjectsPage(limit, offset)→GET /api/admin/objects(the paginated admin list already built). Left pane renders rows; selecting one routes to/objects/:id./objects/:id→useObject(id)→GET /api/admin/objects/{id}. Right pane renders inventory-minimum fields + flexible-field values + visibility badge; 404 → not-found state.- Login:
useLogin→POST /api/admin/login(session cookie set); on success invalidateuseMeand navigate to/objects. Logout:POST /api/admin/logout→ clear →/login.
Routing
| Path | Access | Renders |
|---|---|---|
/login |
public | login page (redirects to /objects if already authed) |
/ |
protected | redirect → /objects |
/objects |
protected | two-pane; empty right pane prompt |
/objects/:id |
protected | two-pane; right pane = record |
* |
protected | redirect → /objects |
RequireAuth wraps protected routes; unauthenticated → /login.
Error / loading / empty states
- List: loading skeleton; empty ("no objects yet"); error with retry.
- Detail: loading; 404 not-found; error with retry.
- Login: inline invalid-credentials (401) and network-error messages.
- Visibility: i18n'd badge — draft (neutral) / internal (amber) / public (green).
- All user-facing copy lives in
en.json+sv.json; language switch persists tolocalStorage.
Testing & CI
- Vitest + RTL + MSW.
test/setup.tsstarts an MSW server;handlers.tsreturns realistic responses typed againstschema.d.ts. Coverage for M1:- API client + query hooks (success + error mapping).
- Login flow: success → navigates; 401 → inline error.
- Objects list: renders rows, pagination controls, empty + error states.
- Object detail: renders fields + visibility; 404 state.
- Language switch toggles copy.
RequireAuthredirects unauthenticated users to/login.
- CI (new
webjob):pnpm install→tsctypecheck → eslint →vitest run→vite build→ bundle-size check (initial JS ≤ 150 KB gz).
Acceptance criteria (Milestone 1 "done")
pnpm dev+ the running server: can log in, see a paginated object list, select a row, read its detail (incl. flexible-field values), switch sv/en, and log out.- Unauthenticated access to a protected route redirects to
/login; a 401 mid-session bounces to/login. cargo build -p server --features embed-web(afterpnpm build) produces a binary that serves the SPA at/with working client-side routing; backendcargo teststill passes without a built frontend.webCI job green: typecheck, lint, tests, build, bundle-size within budget.- Later nav items are visible but disabled.
Out of scope / follow-ups
- Playwright e2e smoke (later milestone, once
--seeddata exists; relates to issue #14). - Object create/edit/delete + dynamic field forms (Milestone 2).
- Any write operations from the UI (M1 is read-only beyond auth).