# B2+B3: Next.js UI Port + PWA Design **Date:** 2026-05-02 **Status:** Approved (design) **Sequel to:** [2026-05-02-b1-nextjs-scaffold-design.md](2026-05-02-b1-nextjs-scaffold-design.md) **Followed by:** B4 (cutover — delete `app/cm_web_view.py`, retire `cm-web` Flask service, rename `cm-web-next` → `cm-web`). ## Problem B1 stood up the Next.js scaffold with no UI and no public `/api/*`. We now need to port the Flask `cm_web_view.py` UI to Next.js and ship a Progressive Web App so operators can install it on their phones. The legacy Flask UI: - Two tabs: **Accounts** (4 columns: username, password, status, link) and **Users** (5 columns: f_username, f_password, t_username, t_password, last_update_time). - Inline cell editing — click → input → save POST → refresh row. - Sortable username column with prefix-priority logic (configured prefix on top, then descending). - Auto-refresh every 30s. - Stats cards (count of each table). - Refresh button. The new Next.js implementation must preserve every operator-facing capability without exposing the four `/api/*` endpoints publicly (per the B1 architecture: RSC for reads, Server Actions for writes). ## Goal Bundle B2 (UI port) and B3 (PWA) into one cycle that ships a fully functional `cm-web-next` capable of replacing `cm-web` (Flask) entirely. The legacy service remains running in parallel until **B4** cuts over. ## Non-Goals - **B4 cutover** — `app/cm_web_view.py` and the `cm-web` service stay untouched. - **Service worker / offline mode** — manifest + installability is the PWA scope. An internal CRUD tool that needs the api-server to function offers no value offline; adding a service worker for a "feels native" sake is YAGNI for now. - **Authentication** — aaPanel C3 (basic auth) handles auth at the proxy. No app-level login flow. - **New features beyond Flask parity** — no bulk edit, no search, no filtering. Same surface as today, just on a better stack. - **Tests** — vitest setup is captured as an out-of-scope follow-up. The UI is exercised end-to-end via the dev stack; logic-heavy parts (Server Actions) are simple wrappers around `fetch`. ## Architecture ### Routing URL-based tabs via App Router parallel routes: | URL | Server Component | Renders | |---|---|---| | `/` | `app/page.tsx` | Accounts table | | `/users/` | `app/users/page.tsx` | Users table | Each page is a Server Component that fetches data from `api-server:3000` over the docker network (no client-side fetch, no public JSON). The shared layout (`app/layout.tsx`) renders nav + the active page. URL-based tabs (vs single-page state-based tabs) means: - Refresh works (you stay on the right tab). - Shareable URLs. - No `'use client'` wrapper needed for the page itself — the page is a Server Component. - Loading and error states get clean Suspense/error boundaries via `loading.tsx` and `error.tsx`. ### Data flow ``` Browser ─GET─▶ web-next (Next.js) ─server-side fetch─▶ api-server:3000/acc/ ▲ │ │ rendered HTML / RSC payload │ JSON └────────────────────────────────────────────────────────┘ ``` Mutations: ``` Browser ─POST (Server Action)─▶ web-next ─fetch─▶ api-server:3000/update-acc-data │ revalidatePath('/') │ (re-renders with fresh data) ``` The `useOptimistic` hook (React 19) gives instant cell update on save — server action runs in the background; UI rolls back on failure. ### File structure ``` web/ ├── app/ │ ├── layout.tsx ← rewrite (frontend-design): nav, theme-color metadata, manifest link │ ├── globals.css ← unchanged from B1 │ ├── page.tsx ← rewrite (frontend-design): accounts dashboard │ ├── users/ │ │ └── page.tsx ← new (frontend-design): users dashboard │ ├── actions.ts ← new (hand-written): updateAcc, updateUser Server Actions │ ├── error.tsx ← new (frontend-design): top-level error boundary UI │ ├── icon.tsx ← new (frontend-design): Next.js auto-generates favicon PNG │ ├── apple-icon.tsx ← new (frontend-design): Next.js auto-generates apple-touch-icon │ └── manifest.ts ← new (hand-written): PWA manifest config ├── components/ │ ├── accounts-table.tsx ← new (frontend-design): client component, inline editing │ ├── users-table.tsx ← new (frontend-design): client component, inline editing │ ├── editable-cell.tsx ← new (frontend-design): generic inline-edit primitive │ ├── nav.tsx ← new (frontend-design): top nav with two tabs │ └── auto-refresh.tsx ← new (frontend-design): client component that triggers revalidation every 30s ├── lib/ │ ├── api.ts ← new (hand-written): server-side fetch helpers │ └── types.ts ← new (hand-written): TypeScript types matching api-server JSON └── public/ ← unchanged from B1 (.gitkeep only) ``` `web/lib/api-paths.ts` and `web/app/api/` are not introduced — the architecture pivot in B1 stands. ### `frontend-design` brief structure frontend-design generates **all TSX** in this cycle. The brief that gets handed to it covers (in one invocation, returning many files): - A coherent visual identity replacing the brutalist construction-tape scaffold. Whatever frontend-design picks must read as a real production dashboard, not a placeholder. The scaffold's job was "obviously temporary"; B2's job is "obviously the real UI" — clear visual distinction so anyone who sees both knows which is which. - Two-page layout (Accounts and Users) with a shared nav. - Inline cell editing UX: hover affordance → click → text input → save (button or Enter) / cancel (button or Escape) → optimistic update. - Sortable column on username with prefix-priority logic baked in. The prefix is read from `process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN` (or hardcoded `13c` if absent) — passed through `lib/api.ts` to the table component as a prop. - Status badges for `acc.status`: empty/wait/done = three visually distinct states. - Refresh button (forces revalidation; equivalent to `router.refresh()`). - Error boundary visual: catches the api-server-unreachable case, shows a clear "API unavailable" message with retry. - Manifest icon design (`icon.tsx`, `apple-icon.tsx`) — uses Next.js's `ImageResponse` API to render an SVG-style icon to PNG. Should match the dashboard's visual identity, not the scaffold's hazard tape. - Mobile-first responsive: tables collapse to card stacks below 640px breakpoint. ### Server Actions (`app/actions.ts`) Hand-written: ```typescript "use server"; import { revalidatePath } from "next/cache"; import { fetchApi } from "@/lib/api"; export async function updateAccount(data: { username: string; password: string; status: string; link: string; }): Promise<{ ok: boolean; error?: string }> { try { await fetchApi("/update-acc-data", { method: "POST", body: data }); revalidatePath("/"); return { ok: true }; } catch (err) { return { ok: false, error: String(err) }; } } export async function updateUser(data: { f_username: string; f_password: string; t_username: string; t_password: string; }): Promise<{ ok: boolean; error?: string }> { try { await fetchApi("/update-user-data", { method: "POST", body: data }); revalidatePath("/users"); return { ok: true }; } catch (err) { return { ok: false, error: String(err) }; } } ``` Each action returns a discriminated `{ ok, error? }` so the client component can show a toast/error indicator without throwing. ### `lib/api.ts` (server-side fetch helper) ```typescript const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000"; export async function fetchApi( path: string, options: { method?: "GET" | "POST"; body?: unknown; cache?: RequestCache } = {}, ): Promise { const url = `${API_BASE_URL}${path}`; const init: RequestInit = { method: options.method ?? "GET", cache: options.cache ?? "no-store", headers: options.body ? { "content-type": "application/json" } : undefined, body: options.body ? JSON.stringify(options.body) : undefined, }; const res = await fetch(url, init); if (!res.ok) { throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`); } return res.json(); } export async function getAccounts() { const data = await fetchApi("/acc/"); return data as Acc[]; } export async function getUsers() { const data = await fetchApi("/user/"); return data as User[]; } ``` `cache: "no-store"` ensures every request hits api-server fresh — RSC caching would stale the dashboard. Combined with `revalidatePath` on mutations, this gives correct re-render behavior. ### `lib/types.ts` ```typescript export type Acc = { username: string; password: string; status: string; link: string; }; export type User = { f_username: string; f_password: string; t_username: string; t_password: string; last_update_time: string | null; }; ``` Mirrors the SQL schema from the dev seed and the api-server's column projection. ### PWA — `app/manifest.ts` Next.js 15 supports `app/manifest.ts` exporting a function that returns the manifest JSON. Next renders it at `/manifest.webmanifest` automatically. ```typescript import type { MetadataRoute } from "next"; export default function manifest(): MetadataRoute.Manifest { return { name: "CM Bot V2", short_name: "CM Bot", description: "CM Bot account and user dashboard", start_url: "/", display: "standalone", orientation: "portrait", background_color: "#ffffff", theme_color: "#000000", icons: [ { src: "/icon", sizes: "any", type: "image/png" }, { src: "/apple-icon", sizes: "180x180", type: "image/png" }, ], }; } ``` The `theme_color` value is finalized by frontend-design (matching the chosen aesthetic) — `#000000` here is a placeholder the implementation will overwrite once the design lands. ### Icons via `app/icon.tsx` and `app/apple-icon.tsx` Next.js 15 generates `/icon` and `/apple-icon` as PNGs at build time from any TSX component returning JSX (rendered via `next/og`'s `ImageResponse`). frontend-design designs the icon JSX directly — no external SVG-to-PNG tooling, no checked-in PNG binaries, no manual asset pipeline. Example shape (frontend-design provides the actual JSX): ```typescript import { ImageResponse } from "next/og"; export const size = { width: 512, height: 512 }; export const contentType = "image/png"; export default function Icon() { return new ImageResponse( (/* JSX returning a styled
with brand mark */), size, ); } ``` `apple-icon.tsx` follows the same pattern with `size = { width: 180, height: 180 }`. ### Auto-refresh The legacy Flask UI auto-refreshes every 30s via `setInterval`. We preserve that behavior with a tiny client component: ```typescript "use client"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; export default function AutoRefresh({ intervalMs = 30000 }: { intervalMs?: number }) { const router = useRouter(); useEffect(() => { const id = setInterval(() => router.refresh(), intervalMs); return () => clearInterval(id); }, [router, intervalMs]); return null; } ``` `router.refresh()` re-runs the Server Component fetch and patches the result in — no full page reload, no flicker. Mounted in the layout so it covers both pages. ### Error boundary (`app/error.tsx`) ```typescript "use client"; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return ( /* frontend-design owns visuals: clear "API unavailable" message, prominent retry button, mention of contacting the operator */ ); } ``` Catches every render-time and Server-Action-time error in the route subtree. The most likely failure mode is `api-server` unreachable; the error boundary turns a stack trace into a usable retry screen. ### Compose / Dockerfile / scripts — no changes `docker-compose.yml`, `docker-compose.override.yml`, `docker/web-next/Dockerfile`, `scripts/dev.sh`, `scripts/publish.sh` are untouched. The build step picks up the new files automatically because `COPY web/ ./` in the Dockerfile copies everything under `web/`. The `npm install` layer (no lockfile yet) re-resolves, but `package.json` doesn't change in this cycle — no new deps. (Next.js 15's `ImageResponse` for icons is in `next/og`, already shipped with Next.js.) ## Files Created / Modified | File | Operation | Owner | |---|---|---| | `web/app/layout.tsx` | Rewrite | frontend-design | | `web/app/page.tsx` | Rewrite | frontend-design | | `web/app/users/page.tsx` | Create | frontend-design | | `web/app/error.tsx` | Create | frontend-design | | `web/app/icon.tsx` | Create | frontend-design | | `web/app/apple-icon.tsx` | Create | frontend-design | | `web/app/actions.ts` | Create | hand-written | | `web/app/manifest.ts` | Create | hand-written | | `web/components/accounts-table.tsx` | Create | frontend-design | | `web/components/users-table.tsx` | Create | frontend-design | | `web/components/editable-cell.tsx` | Create | frontend-design | | `web/components/nav.tsx` | Create | frontend-design | | `web/components/auto-refresh.tsx` | Create | hand-written | | `web/lib/api.ts` | Create | hand-written | | `web/lib/types.ts` | Create | hand-written | No file deletions. No changes outside `web/`. ## Verification 1. **Build succeeds.** `bash scripts/dev.sh up` brings up the stack; `dev-cm-web-next` starts without webpack errors. 2. **Accounts table renders.** `curl -sf http://localhost:8010/ | grep -E "13c1000|13c1011"` returns hits — the seed accounts from the dev MySQL are visible. (Dev DB has 4 available + 2 'done' accounts seeded by `docker/mysql/init.d/02-seed.sql`.) 3. **Users table renders.** `curl -sf http://localhost:8010/users/ | grep -E "player_one_seed|player_two_seed"` returns hits — the seeded user pairings show up. 4. **Inline edit (acc).** Open `http://localhost:8010/` in a browser. Click an editable cell on row `13c1010`. Change a value. Save. The cell updates instantly (optimistic), then the page revalidates. Verify in mysql: `mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT * FROM acc WHERE username='13c1010'"` shows the new value. 5. **Inline edit (user).** Same flow on `/users/`. 6. **Sort.** Click the username header (or whatever the sortable affordance is). Order flips between ascending and descending; prefix-priority is preserved (rows starting with `13c` stay on top). 7. **Refresh button.** Click it; the data round-trips api-server. Look for the network HTML/RSC re-fetch in browser devtools. 8. **Auto-refresh.** Wait 30 seconds with the page open; observe the network tab fire a fetch automatically. 9. **Error boundary.** With dev stack up, `sudo docker stop dev-cm-api-server`, then refresh `http://localhost:8010/`. The error boundary renders. `sudo docker start dev-cm-api-server`, click retry → page recovers. 10. **PWA install.** On Chrome (desktop or Android), the address bar shows an "Install" affordance. Install. The app opens chromeless. The icon shows the frontend-design icon, not a generic letter glyph. 11. **iOS Add-to-Home.** On Safari iOS, "Add to Home Screen" — the home-screen icon shows the apple-touch-icon (180x180), title is "CM Bot". 12. **Legacy Flask still works.** `curl -sf http://localhost:8000/` returns the Flask HTML page unchanged. 13. **No public API.** `curl -i http://localhost:8010/api/anything/` returns 404 (or 308 → 404, per B1). ## Risk Medium. - **Server Action call sites are sprinkled across client components.** A mismatch between the action's argument shape and what the client passes silently fails until you trigger the action. Mitigated by importing the action's TypeScript signature into the client component (compile-time check). - **`useOptimistic` rollback on error.** If the Server Action returns `{ ok: false }`, the client must reverse the optimistic update. Easy to forget. The `editable-cell.tsx` primitive owns this so each table doesn't have to. - **`revalidatePath` cache semantics.** Mutating `/api/acc` revalidates `/`; mutating `/api/user` revalidates `/users`. If a future feature crosses that boundary (e.g., editing an acc that's also referenced by a user row), we'd need to revalidate both. Out of scope for now. - **iOS "Add to Home Screen" doesn't honor `manifest.webmanifest`.** iOS uses `apple-icon.tsx` and the `` from `app/layout.tsx`; the manifest is mostly a hint. The two icon files (`icon.tsx`, `apple-icon.tsx`) cover both Android (manifest-driven) and iOS. - **First Docker build still slow.** No lockfile means `npm install` re-resolves every cache miss. Acceptable for B2; revisit if iteration speed becomes painful. - **Auto-refresh + inline edit collision.** If a user is mid-edit when the 30s auto-refresh fires, `router.refresh()` re-renders the table. The editable cell needs to detect "currently editing" and skip the rerender for that cell — handled in `editable-cell.tsx` by anchoring edit state outside the cell's `key` and only refreshing when no cell is in editing mode (or by debouncing the refresh during edits). ## Out-of-Scope Follow-Ups - **B4 cutover** — separate cycle: delete `app/cm_web_view.py`, retire `cm-web` service, rename `cm-web-next` → `cm-web` (image, container name, compose service name). - **Vitest setup + tests** for `lib/api.ts` and the Server Actions. The current verification is end-to-end; component-level tests are valuable but not yet structured. - **Service worker** for offline fallback. Internal CRUD doesn't need it. If we ever want operators to "see the last-known data" while offline, a Workbox-generated SW is a small lift. - **Bulk edit / search / filter** in the tables — not in current Flask UI either. - **i18n / dark mode / theme toggle** — punt until someone asks. - **Real-time updates via SSE or WebSocket** — current 30s polling is fine for the operator workflow; rebuilding to push-based is its own design problem.