18 KiB
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
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.pyand thecm-webservice 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.tsxanderror.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 hardcoded13cif absent) — passed throughlib/api.tsto 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'sImageResponseAPI 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:
"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)
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<unknown> {
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
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.
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):
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 <div> 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:
"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)
"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
- Build succeeds.
bash scripts/dev.sh upbrings up the stack;dev-cm-web-nextstarts without webpack errors. - 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 bydocker/mysql/init.d/02-seed.sql.) - 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. - Inline edit (acc). Open
http://localhost:8010/in a browser. Click an editable cell on row13c1010. 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. - Inline edit (user). Same flow on
/users/. - Sort. Click the username header (or whatever the sortable affordance is). Order flips between ascending and descending; prefix-priority is preserved (rows starting with
13cstay on top). - Refresh button. Click it; the data round-trips api-server. Look for the network HTML/RSC re-fetch in browser devtools.
- Auto-refresh. Wait 30 seconds with the page open; observe the network tab fire a fetch automatically.
- Error boundary. With dev stack up,
sudo docker stop dev-cm-api-server, then refreshhttp://localhost:8010/. The error boundary renders.sudo docker start dev-cm-api-server, click retry → page recovers. - 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.
- 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".
- Legacy Flask still works.
curl -sf http://localhost:8000/returns the Flask HTML page unchanged. - 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).
useOptimisticrollback on error. If the Server Action returns{ ok: false }, the client must reverse the optimistic update. Easy to forget. Theeditable-cell.tsxprimitive owns this so each table doesn't have to.revalidatePathcache semantics. Mutating/api/accrevalidates/; mutating/api/userrevalidates/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 usesapple-icon.tsxand the<title>fromapp/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 installre-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 ineditable-cell.tsxby anchoring edit state outside the cell'skeyand 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, retirecm-webservice, renamecm-web-next→cm-web(image, container name, compose service name). - Vitest setup + tests for
lib/api.tsand 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.