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

6.0 KiB

name description
frontend 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:

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:
    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.