--- 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` 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 `` 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 `
` overlay — that's been replaced with ``. 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.