From 2de545e8543144b4b89849d65837cd4d6d3f8dcd Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 20:45:52 +0800 Subject: [PATCH] Add design spec for B2+B3 (UI port + PWA) --- .../2026-05-02-b2-b3-ui-port-pwa-design.md | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md diff --git a/docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md b/docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md new file mode 100644 index 0000000..17c27b3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md @@ -0,0 +1,369 @@ +# 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.