Add design spec for B2+B3 (UI port + PWA)

This commit is contained in:
yiekheng 2026-05-02 20:45:52 +08:00
parent 21bb1f0dde
commit 2de545e854

View File

@ -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<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`
```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 <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:
```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 `<title>` 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.