feat(web): convert the UI to a React + Vite + TypeScript SPA #2

Merged
MalcolmMielle merged 2 commits from malcolmmielle/react-migration into main 2026-10-29 09:50:09 +00:00 AGit

Summary

Convert the Federated Reviews web interface from Jinja2 + vanilla JS to a React + Vite + TypeScript SPA in a new self-contained react-app/ folder, per the handoff spec REACT-MIGRATION.md (committed at the repo root). The backend stays FastAPI with its architecture unchanged; FastAPI now serves the built SPA at / with a catch-all registered after all API / ActivityPub / auth / /static routes so it never shadows them. Desktop keeps the original look (380 px sidebar, 400 px panels, identical design tokens); phones get a real mobile-first layout (draggable bottom sheet, floating top search, full-screen slide-up detail/form, 100dvh, ≥ 44 px touch targets) replacing the old single @media hack. The bundle targets es2020 so the same build can later run in a Sailfish Sailfish.WebView shell.

Merged with current main (which had moved ahead with CSRF middleware, Nominatim geocode proxies, a pytest suite, corrected follows semantics, and a package reorganization): the SPA now uses main's /api/geocode + /api/reverse-geocode proxies instead of calling Nominatim directly, posts the session CSRF token with its form-encoded logout (JSON requests are CSRF-exempt), and GET /api/me returns the token alongside the user. My earlier fan-out fix was dropped — main already resolves follower inbox URLs (get_follower_inboxes). One pre-existing bug fix remains: HTTP Signatures now cover the Host: host:port header exactly as the client sends it, which makes the documented two-instance federation test (ports 8000/8001) actually pass; standard-port behaviour is unchanged.

Changes

  • react-app/ (new; own package.json, root package.json stays {}): Vite + React + TypeScript + react-leaflet scaffold; build.target: "es2020" (Sailfish Gecko requirement); dev server.proxy on 5173 forwarding /api, /actor, /.well-known, /login, /register, /logout, /review, /static to :8000; .gitignore extended for node_modules/ + dist/.
  • Full port of app/static/app.js (503 lines) into components: App (fetches /api/me), MapPage (layout/state/data flow), MapView (react-leaflet: OSM tiles, rating-coloured teardrop divIcon markers, popups, moveend bounds-filtered reload, click-to-place, draggable placement marker, focus padding), ReviewList, ReviewDetail, ReviewForm (place search, address auto-fill, star rating), SearchBox, reusable debounced NominatimSearch (350 ms), StarRating, plus useMediaQuery / useBottomSheet hooks. style.css ported with identical design tokens (IDs → classes; panel show/hide as mount keyframes). React JSX replaces the old innerHTML/escHtml rendering, so popups and lists are XSS-safe by construction.
  • Mobile-first redesign: sidebar becomes a draggable bottom sheet (peek 140 px / expanded 85 dvh, pointer-drag + tap), global search floats over the map at the top, the + FAB floats above the sheet (bottom transitions with sheet state), detail and form panels are full-screen slide-up sheets, 100dvh heights with 100vh fallback, ≥ 44 px touch targets. Map focus padding adapts per viewport.
  • Backend (small): GET /api/me → {id, username, display_name, csrf_token} or 401 JSON; the _LoginRequired handler returns 401 {"detail": ...} when the request's Accept includes application/json (or X-Requested-With is set), keeping the 303 → /login redirect for plain browser form posts; POST /api/reviews accepts JSON bodies in addition to form-encoded, with 422 validation; main.py mounts react-app/dist/assets and registers the SPA catch-all last; the old GET / Jinja route removed; app/static/app.js and app/templates/index.html deleted; base.html no longer loads Leaflet from unpkg; style.css kept (still used by the remaining Jinja pages) with a pointer comment.
  • Pre-existing bug fix (kept): app/activitypub/signing.py — sign_request signed the Host header without the port, so signature verification failed against any server on a non-standard port (exactly the README's two-instance setup). Now signs the host exactly as the HTTP client sends it (default ports still omitted). Locked in with a new test.
  • Tests updated for the SPA (the suite grew from 55 → 68 on main since the spec was written): all get_csrf_token(client, "/") call sites moved to /register (the index page no longer renders a form), test_index_page is now SPA-aware (200 + React root when react-app/dist is built, 404 with the npm run build hint otherwise), plus one new signing test.
  • README merged with main's version: quick start installs/builds react-app/, hot-reload dev section, project layout, tech stack, API reference (incl. /api/me), and the review-creation smoke test now logs in with a CSRF token + cookie jar and shows form + JSON variants.
  • Deferred (future jobs, per the spec): sailfish-app/ Qt/QML shell, Capacitor wrapper, JWT/token auth + JSON auth endpoints, PWA, pagination UI.

Testing

  • uv run pytest → 68 passed (67 from main + 1 new signing test)
  • uv run ruff check app/ tests/ → all checks passed; ruff format --check clean on all touched files (4 unformatted files pre-exist on main, left untouched to keep the diff focused)
  • uv run basedpyright app/ → 0 errors
  • npm install && npm run build (tsc strict + Vite, es2020) → green; npm run dev proxy verified via curl (API/auth/ActivityPub/static proxied, session cookie works through the proxy)
  • Backend smoke (curl, rebased tree): SPA at / + deep links; /api/me 401 unauth / 200 with csrf_token; login/logout with CSRF → 303; form-encoded review POST with csrf → 200; JSON POST (CSRF-exempt) → 200; missing-csrf → 403; unauth JSON POST → 401 JSON; unauth form POST → 303 → /login; geocode proxies → 200; WebFinger / actor (Accept: application/activity+json) / outbox / followers → 200; /review/{id} SSR → 200; /static/app.js → 404
  • Browser e2e (Playwright/Chromium against the FastAPI-served production build) → 30/30: desktop — markers, sidebar list matches API count, register flow, user strip, card → detail panel open/close, form open, map-click placement, submit creates a review and it appears in the refreshed list, logout returns to logged-out; mobile 390×844 — bottom sheet peek/expand/collapse via handle, FAB above sheet, floating top search, full-screen slide-up detail; search/drag suite — geocode dropdowns, address auto-fill, dragging the placement marker updates the coordinates chip. No uncaught page errors, no console errors
  • Two-instance federation e2e → pass: signed Follow from bob@B to alice@A auto-accepted (Accept returned, followers collection updated), a review created on A federated to B and appears in B's /api/reviews (fan-out ok:1 / fail:0)
  • Note: this repo has no CI configured (no workflow files); all checks above were run locally. The e2e also caught and fixed a port regression: react-leaflet re-calls popup.update() when a Popup's children identity changes while open, and Leaflet auto-pans the map to the popup — fixed by memoizing marker positions and popup children.

Screenshots

Same scenarios, same viewports — before (main, Jinja + vanilla JS) vs after (this branch, React SPA). Images are pinned to a review-only assets ref (assets/react-migration) so they survive branch updates:

Scenario Before (main) After (this PR)
Desktop map before after
Mobile map (bottom sheet) before after
Review form (marker placed) before after
## Summary Convert the Federated Reviews web interface from Jinja2 + vanilla JS to a **React + Vite + TypeScript SPA** in a new self-contained `react-app/` folder, per the handoff spec `REACT-MIGRATION.md` (committed at the repo root). The backend stays FastAPI with its architecture unchanged; FastAPI now serves the built SPA at `/` with a catch-all registered **after** all API / ActivityPub / auth / `/static` routes so it never shadows them. Desktop keeps the original look (380 px sidebar, 400 px panels, identical design tokens); phones get a real mobile-first layout (draggable bottom sheet, floating top search, full-screen slide-up detail/form, `100dvh`, ≥ 44 px touch targets) replacing the old single `@media` hack. The bundle targets `es2020` so the same build can later run in a Sailfish `Sailfish.WebView` shell. Merged with current `main` (which had moved ahead with CSRF middleware, Nominatim geocode proxies, a pytest suite, corrected follows semantics, and a package reorganization): the SPA now uses main's `/api/geocode` + `/api/reverse-geocode` proxies instead of calling Nominatim directly, posts the session CSRF token with its form-encoded logout (JSON requests are CSRF-exempt), and `GET /api/me` returns the token alongside the user. My earlier fan-out fix was **dropped** — main already resolves follower inbox URLs (`get_follower_inboxes`). One pre-existing bug fix remains: HTTP Signatures now cover the `Host: host:port` header exactly as the client sends it, which makes the documented two-instance federation test (ports 8000/8001) actually pass; standard-port behaviour is unchanged. ## Changes - **`react-app/`** (new; own `package.json`, root `package.json` stays `{}`): Vite + React + TypeScript + react-leaflet scaffold; `build.target: "es2020"` (Sailfish Gecko requirement); dev `server.proxy` on 5173 forwarding `/api`, `/actor`, `/.well-known`, `/login`, `/register`, `/logout`, `/review`, `/static` to `:8000`; `.gitignore` extended for `node_modules/` + `dist/`. - **Full port of `app/static/app.js`** (503 lines) into components: `App` (fetches `/api/me`), `MapPage` (layout/state/data flow), `MapView` (react-leaflet: OSM tiles, rating-coloured teardrop `divIcon` markers, popups, `moveend` bounds-filtered reload, click-to-place, draggable placement marker, focus padding), `ReviewList`, `ReviewDetail`, `ReviewForm` (place search, address auto-fill, star rating), `SearchBox`, reusable debounced `NominatimSearch` (350 ms), `StarRating`, plus `useMediaQuery` / `useBottomSheet` hooks. `style.css` ported with identical design tokens (IDs → classes; panel show/hide as mount keyframes). React JSX replaces the old `innerHTML`/`escHtml` rendering, so popups and lists are XSS-safe by construction. - **Mobile-first redesign**: sidebar becomes a draggable bottom sheet (peek 140 px / expanded 85 dvh, pointer-drag + tap), global search floats over the map at the top, the + FAB floats above the sheet (bottom transitions with sheet state), detail and form panels are full-screen slide-up sheets, `100dvh` heights with `100vh` fallback, ≥ 44 px touch targets. Map focus padding adapts per viewport. - **Backend (small)**: `GET /api/me` → `{id, username, display_name, csrf_token}` or 401 JSON; the `_LoginRequired` handler returns `401 {"detail": ...}` when the request's `Accept` includes `application/json` (or `X-Requested-With` is set), keeping the 303 → `/login` redirect for plain browser form posts; `POST /api/reviews` accepts JSON bodies in addition to form-encoded, with 422 validation; `main.py` mounts `react-app/dist/assets` and registers the SPA catch-all last; the old `GET /` Jinja route removed; `app/static/app.js` and `app/templates/index.html` deleted; `base.html` no longer loads Leaflet from unpkg; `style.css` kept (still used by the remaining Jinja pages) with a pointer comment. - **Pre-existing bug fix (kept)**: `app/activitypub/signing.py` — `sign_request` signed the `Host` header without the port, so signature verification failed against any server on a non-standard port (exactly the README's two-instance setup). Now signs the host exactly as the HTTP client sends it (default ports still omitted). Locked in with a new test. - **Tests updated for the SPA** (the suite grew from 55 → 68 on main since the spec was written): all `get_csrf_token(client, "/")` call sites moved to `/register` (the index page no longer renders a form), `test_index_page` is now SPA-aware (200 + React root when `react-app/dist` is built, 404 with the `npm run build` hint otherwise), plus one new signing test. - **README** merged with main's version: quick start installs/builds `react-app/`, hot-reload dev section, project layout, tech stack, API reference (incl. `/api/me`), and the review-creation smoke test now logs in with a CSRF token + cookie jar and shows form + JSON variants. - **Deferred (future jobs, per the spec)**: `sailfish-app/` Qt/QML shell, Capacitor wrapper, JWT/token auth + JSON auth endpoints, PWA, pagination UI. ## Testing - `uv run pytest` → **68 passed** (67 from main + 1 new signing test) - `uv run ruff check app/ tests/` → all checks passed; `ruff format --check` clean on all touched files (4 unformatted files pre-exist on `main`, left untouched to keep the diff focused) - `uv run basedpyright app/` → 0 errors - `npm install && npm run build` (tsc strict + Vite, es2020) → green; `npm run dev` proxy verified via curl (API/auth/ActivityPub/static proxied, session cookie works through the proxy) - Backend smoke (curl, rebased tree): SPA at `/` + deep links; `/api/me` 401 unauth / 200 with `csrf_token`; login/logout with CSRF → 303; form-encoded review POST with csrf → 200; JSON POST (CSRF-exempt) → 200; missing-csrf → 403; unauth JSON POST → 401 JSON; unauth form POST → 303 → `/login`; geocode proxies → 200; WebFinger / actor (`Accept: application/activity+json`) / outbox / followers → 200; `/review/{id}` SSR → 200; `/static/app.js` → 404 - Browser e2e (Playwright/Chromium against the FastAPI-served production build) → **30/30**: desktop — markers, sidebar list matches API count, register flow, user strip, card → detail panel open/close, form open, map-click placement, submit creates a review and it appears in the refreshed list, logout returns to logged-out; mobile 390×844 — bottom sheet peek/expand/collapse via handle, FAB above sheet, floating top search, full-screen slide-up detail; search/drag suite — geocode dropdowns, address auto-fill, dragging the placement marker updates the coordinates chip. No uncaught page errors, no console errors - Two-instance federation e2e → **pass**: signed Follow from bob@B to alice@A auto-accepted (Accept returned, followers collection updated), a review created on A federated to B and appears in B's `/api/reviews` (fan-out ok:1 / fail:0) - Note: this repo has no CI configured (no workflow files); all checks above were run locally. The e2e also caught and fixed a port regression: react-leaflet re-calls `popup.update()` when a `Popup`'s children identity changes while open, and Leaflet auto-pans the map to the popup — fixed by memoizing marker positions and popup children. ## Screenshots Same scenarios, same viewports — before (`main`, Jinja + vanilla JS) vs after (this branch, React SPA). Images are pinned to a review-only assets ref (`assets/react-migration`) so they survive branch updates: | Scenario | Before (`main`) | After (this PR) | |---|---|---| | Desktop map | ![before](https://forge.mielle.dev/MalcolmMielle/fedreviews/raw/commit/55a9490445c933dbe615c1d595bc2e848a2e8d59/map-desktop-before.png) | ![after](https://forge.mielle.dev/MalcolmMielle/fedreviews/raw/commit/55a9490445c933dbe615c1d595bc2e848a2e8d59/map-desktop-after.png) | | Mobile map (bottom sheet) | ![before](https://forge.mielle.dev/MalcolmMielle/fedreviews/raw/commit/55a9490445c933dbe615c1d595bc2e848a2e8d59/map-mobile-before.png) | ![after](https://forge.mielle.dev/MalcolmMielle/fedreviews/raw/commit/55a9490445c933dbe615c1d595bc2e848a2e8d59/map-mobile-after.png) | | Review form (marker placed) | ![before](https://forge.mielle.dev/MalcolmMielle/fedreviews/raw/commit/55a9490445c933dbe615c1d595bc2e848a2e8d59/review-form-before.png) | ![after](https://forge.mielle.dev/MalcolmMielle/fedreviews/raw/commit/55a9490445c933dbe615c1d595bc2e848a2e8d59/review-form-after.png) |
Port the map page (app/static/app.js, 503 lines) to a React SPA in
react-app/ (Vite + TypeScript + react-leaflet): markers, bounds-filtered
loading, sidebar list, Nominatim search, detail panel and review form,
plus a mobile-first layout (bottom sheet, full-screen panels, 100dvh,
44px touch targets). FastAPI serves react-app/dist with an SPA catch-all
registered after all API/ActivityPub/auth routes. Adds GET /api/me,
401 JSON for fetch callers, and JSON bodies on POST /api/reviews.
Old app.js and the Jinja map template are removed; login/register and
the review detail page stay server-rendered.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MielleLabs/pheme!2
No description provided.