listing_ebay/.claude/skills/frontend/SKILL.md

95 lines
6.0 KiB
Markdown

---
name: frontend
description: Use when adding/modifying anything in FRONTEND/ — new components, modals, socket events, state, routing, Mantine UI, terminal/xterm work. Covers conventions, the App.tsx state-fanout pattern, the SocketContext, the line-buffering renderer, and the modal stacking rules.
---
# FRONTEND skill
Scope: everything under `FRONTEND/` (React 19 + Vite + TypeScript + Mantine 8 + socket.io-client + xterm).
Run from `FRONTEND/`:
- `npm run dev` (Vite, port 5173)
- `npm run build` (`tsc -b && vite build`)
- `npm run lint` (flat-config ESLint)
Env: `VITE_BACKEND_URL` (REST), `VITE_SOCKET_SERVER` (Socket.IO; default `http://localhost:8989/`).
## Conventions to preserve
- **Folder typo is intentional:** `src/untils/` (not `utils`). Imports across the app reference `../untils/...` — do not rename.
- **Mantine 8 only.** Don't reach for raw HTML form elements or other UI kits when a Mantine component exists. Same for `@tabler/icons-react` for icons.
- **Comments mix Vietnamese and English.** Preserve existing language in nearby code.
- **`localStorage.user`** is the single source of truth for the logged-in user. The shape is parsed JSON `{ id, userName, email, ... }`. Use `isJsonString` from `untils/helper` before parsing.
- **`apiUrl` pattern:** every file that calls REST does `const apiUrl = import.meta.env.VITE_BACKEND_URL;` at the top, then `axios.get(apiUrl + "api/...")`. Match it.
## App.tsx is the canonical store
`src/App.tsx` holds `stations: TStation[]` and is the only place that owns line-level state. Everything else reads from props.
When you handle a new socket event that updates a line, **always go through `updateValueLineStation(lineId, updates, stationId)`** — it fans the update out to `selectedLine` and `selectedLines` in lockstep. Manually calling `setStations` will desync the terminal modal from the cards.
`line_output` is the exception: it's batched in `lineBuffersRef: Map<lineId, string>` and flushed every ~50ms by `flushBuffers`. If you add a new high-frequency stream (anything that fires more than ~10 Hz), reuse this batch path — don't dispatch per event, you'll cause per-character React renders that lock up the UI.
## SocketContext is the only place that creates a socket
`src/context/SocketContext.tsx` instantiates one `socket.io-client` connection authed with `{ userId, userName }` and exposes it via `useSocket()`. **Never call `io(...)` from a component.** New components consume the socket via `useSocket()` or accept a `socket: Socket | null` prop from a parent that already has it.
When subscribing in a component, the rules are:
1. Always `socket.off("event", handler)` in the effect cleanup with the same handler reference.
2. Don't subscribe to events that `App.tsx` already listens to — `App.tsx`'s handler will fire too and you'll get double updates. Filter inside your handler instead (e.g. by `stationId`/`lineId`).
3. Re-subscribe when the relevant ID prop changes — include `stationId`/`lineId` in the dependency array.
## Adding a new socket round-trip (FE side)
Pattern, copy-pasted from `ModalLineHistory.tsx`:
```tsx
useEffect(() => {
if (!socket || !opened) return;
const handler = (data: { stationId: number; lineId: number; ...: ... }) => {
if (data?.stationId !== stationId || data?.lineId !== lineId) return;
// setState(...)
};
socket.on("event_name", handler);
return () => { socket.off("event_name", handler); };
}, [socket, opened, stationId, lineId]);
useEffect(() => {
if (!socket || !opened || !stationId || !lineId) return;
socket.emit("event_name_request", { stationId, lineId });
}, [socket, opened, stationId, lineId]);
```
The filter `data.stationId !== stationId || data.lineId !== lineId` is mandatory — the backend broadcasts to the requesting socket, but a single socket can have multiple modals open.
## Modals: prefer `<Modal>` from Mantine
Look at `ModalLineHistory.tsx` for the canonical small modal, `ModalTerminal.tsx` for the large multi-panel one.
- **Stacking:** the terminal modal uses `zIndex={95}`. Modals opened from inside it must use a higher `zIndex` (use `1000000` to stay above any portaled content).
- **Close-guarding:** when adding a new inner modal opened from `ModalTerminal`, add its `open` flag to the close-guard around line `560` of `ModalTerminal.tsx` so Escape on the inner modal doesn't bubble up and close the terminal too:
```ts
if (openSelectIos || openLineHistory || /* your flag */ || ...) return;
```
- **Don't roll a custom backdrop.** The previous version of `ModalLineHistory.tsx` used a fixed-position `<div>` overlay — that's been replaced with `<Modal>`. Don't reintroduce the pattern.
## Types live in `src/untils/types.ts`
`TLine`, `TStation`, `TUser`, `IScenario`, `TBrands`, `TCategories`, `TextFSM`, etc. all come from there. Backend can send the same field in two casings — `lineNumber` vs `line_number`, `stationId` vs `station_id`, `apcName` vs `apc_name`. **Always read both:** `line.lineNumber || line.line_number`. When passing into a new socket emit, normalize to one form.
## Terminal (xterm)
`src/components/TerminalXTerm.tsx` is the only consumer of `xterm` + `@xterm/addon-fit`. It receives `content` (latest delta), `initContent` (full buffer for hydration), `loadingClearTerminal`, and `isClearKeepScrollBack` from `ModalTerminal`. The `[CLEAR_TERMINAL_SCROLL_BACK]` sentinel string in the buffer is the contract for "clear scrollback but keep history" — if you add a new clear mode, follow the same sentinel approach rather than adding a new prop.
## DnD-Kit station tabs
`src/components/DragTabs.tsx` owns the draggable station header. If you add a new tab-level action, plug it in here, not in `App.tsx`.
## Don't
- Don't fix the `untils` typo.
- Don't add a new socket connection.
- Don't bypass `updateValueLineStation` to mutate `stations` directly when a line update arrives.
- Don't add a per-message render — batch via `lineBuffersRef` if frequency is high.
- Don't mock UI/UX changes only with type-checks; for any visible change, run `npm run dev` and walk through the affected flow in the browser before reporting done.