Add design spec for B2+B3 (UI port + PWA)
This commit is contained in:
parent
21bb1f0dde
commit
2de545e854
369
docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md
Normal file
369
docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user