# Frontend Development — Rules & Guidelines Rules and conventions every frontend developer must follow. Read this before writing your first line of code. --- ## 1. Language & Typing - **TypeScript** is mandatory — no plain JavaScript files (`.js`, `.jsx`) in the codebase. - Use **strict mode** (`"strict": true` in `tsconfig.json`) — the project must compile with zero errors. - Never use `any`. If a type is truly unknown, use `unknown` and narrow it with type guards. - Prefer **interfaces** for object shapes that may be extended, **type aliases** for unions, intersections, and utility types. - Every function must have explicit parameter types and return types — including React components (`React.FC` is discouraged; type props and return `JSX.Element` explicitly). - Use `T | null` or `T | undefined` instead of `Optional` patterns — be explicit about nullability. - Use `as const` for constant literals and enums where it improves type narrowness. - Run `tsc --noEmit` in CI — the codebase must pass with zero type errors. ```tsx // Good interface BanEntry { ip: string; jail: string; bannedAt: string; expiresAt: string | null; } function BanRow({ ban }: { ban: BanEntry }): JSX.Element { return {ban.ip}{ban.jail}; } // Bad — untyped, uses `any` function BanRow({ ban }: any) { return {ban.ip}; } ``` --- ## 2. Reusable Types - All **shared type definitions** live in a dedicated `types/` directory. - Group types by domain: `types/ban.ts`, `types/jail.ts`, `types/auth.ts`, `types/api.ts`, etc. - Import types using the `import type` syntax when the import is only used for type checking — this keeps the runtime bundle clean. - Component-specific prop types may live in the same file as the component, but any type used by **two or more files** must move to `types/`. - Never duplicate a type definition — define it once, import everywhere. - Export API response shapes alongside their domain types so consumers always know what the server returns. ```ts // types/ban.ts export interface Ban { ip: string; jail: string; bannedAt: string; expiresAt: string | null; banCount: number; country: string | null; } export interface BanListResponse { bans: Ban[]; total: number; } ``` ```tsx // components/BanTable.tsx import type { Ban } from "../types/ban"; ``` --- ## 2.5. Generated Types from OpenAPI Schema **Critical:** Frontend types are automatically generated from the backend's OpenAPI schema to prevent type drift. This ensures frontend types always match the backend's Pydantic models. ### Workflow 1. **Backend exposes OpenAPI schema** at `GET /api/openapi.json` (enabled automatically by FastAPI). 2. **Frontend builds include type generation:** ```bash npm run generate:types # Generates src/types/generated.ts npm run build # Automatically calls generate:types first ``` 3. **Generated types** (`src/types/generated.ts`) are committed to version control and imported wherever needed. 4. **CI validation** ensures generated types stay in sync with the backend: ```bash npm run validate:types # Fails if types differ from backend schema ``` ### When Backend Models Change 1. Modify the Pydantic model in `backend/app/models/` as needed. 2. Ensure the change is reflected in the OpenAPI schema (FastAPI does this automatically). 3. On the next build, `npm run generate:types` regenerates types from the new schema. 4. The build fails if types differ (pre-commit hook can catch this locally). 5. Commit the updated `src/types/generated.ts` alongside your backend changes. ### CI/CD Integration Add `npm run validate:types` to your CI pipeline to catch type drift: ```yaml # GitHub Actions example - name: Validate types run: npm run validate:types ``` The script returns: - **Exit 0:** Types are in sync - **Exit 1:** Generated types differ (run `npm run generate:types` to fix) - **Exit 2:** Backend is not accessible (set `BANGUI_BACKEND_URL` if needed) - **Exit 3:** Type generation failed --- ## 3. Type Safety in API Calls - Every API call must have a **typed request** and **typed response**. - Define response shapes as TypeScript interfaces in `types/` and cast the response through them. - Use a **central API client** (e.g., a thin wrapper around `fetch` or `axios`) that returns typed data — individual components never call `fetch` directly. - Validate or assert the response structure at the boundary when dealing with untrusted data; for critical flows, consider a runtime validation library (e.g., `zod`). - API endpoint paths are **constants** defined in a single file (`api/endpoints.ts`) — never hard-code URLs in components. - **All API functions that perform a `GET` request must accept an optional `signal?: AbortSignal` parameter and forward it to the HTTP client.** This enables hooks to cancel in-flight requests when components unmount, preventing silent state-update errors and wasted resources. When an API function calls another internal API function, thread the signal through to the underlying call. ```ts // api/client.ts const BASE_URL = import.meta.env.VITE_API_URL ?? "/api"; async function get(path: string, signal?: AbortSignal): Promise { const response: Response = await fetch(`${BASE_URL}${path}`, { credentials: "include", signal, }); if (!response.ok) { throw new ApiError(response.status, await response.text()); } return (await response.json()) as T; } export const api = { get, post, put, del } as const; ``` ```ts // api/bans.ts import type { BanListResponse } from "../types/ban"; import { api } from "./client"; export async function fetchBans(hours: number, signal?: AbortSignal): Promise { return api.get(`/bans?hours=${hours}`, signal); } ``` ```ts // hooks/useBans.ts const ctrl = new AbortController(); fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount .then(resp => { /* ... */ }) .catch(err => { /* ... */ }); ``` ### CSRF Protection Header All state-mutating requests (POST, PUT, DELETE, PATCH) automatically include the custom header `X-BanGUI-Request: 1` via the central API client. This protects against Cross-Site Request Forgery (CSRF) attacks by requiring a custom header that cross-site JavaScript cannot set without CORS preflight. **How it works:** - The `request()` function in `api/client.ts` conditionally sets headers based on the request method and body: - `Content-Type: application/json` is only set for requests with a body (POST, PUT, DELETE with body) to avoid unnecessary CORS preflights on GET requests. - `X-BanGUI-Request: 1` is only set for state-mutating requests (POST, PUT, DELETE, PATCH). - GET, HEAD, and OPTIONS requests are unaffected (no CSRF header, no Content-Type header). - Bearer token authentication bypasses the check (tokens are not CSRF-vulnerable). - The backend `CsrfMiddleware` validates this header for cookie-authenticated state-mutating requests. - Requests missing the header receive a `403 Forbidden` response. **No Action Required:** As a developer, you do not need to manually add this header — the centralized API client handles it automatically. All `api.post()`, `api.put()`, `api.del()` calls will include it. ### Request Deduplication & Shared Caching When multiple components mount simultaneously and need the same data, **implement shared hooks with request deduplication** to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request: - Create a custom hook with module-level state to track in-flight requests - When multiple hook instances request the same data concurrently, they await the same promise - Implement cache invalidation via an exported function that notifies all subscribers - Consumers call the shared hook instead of raw API functions ```ts // hooks/useSharedSetupStatus.ts — shared, deduplicated setup status const subscribers: Set<() => void> = new Set(); let cache: CacheEntry | null = null; export function invalidateSetupStatus(): void { cache = null; subscribers.forEach(notify => notify()); } export function useSharedSetupStatus(): UseSharedSetupStatusResult { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const refresh = useCallback(async () => { const now = Date.now(); const isCacheValid = cache && now - cache.timestamp < 30000; if (!isCacheValid) { cache = { promise: getSetupStatus(), timestamp: now, }; } const result = await cache.promise; setStatus(result); }, []); useEffect(() => { void refresh(); subscribers.add(refresh); return () => { subscribers.delete(refresh); }; }, [refresh]); return { status, loading, error, refresh }; } ``` **When to use shared hooks:** - When a critical status or configuration is checked by multiple components on mount (e.g., setup completion, session validation, feature flags) - When concurrent requests for the same data waste backend resources or introduce race conditions - When cache TTL is short and invalidation is simple **Guidelines:** - Shared hooks should be used in low-level consumer code (direct consumers of the setup flow) - The cache can be **invalidated explicitly** after mutations (e.g., after setup completes, call `invalidateSetupStatus()`) - Cache TTL should be relatively short (30 seconds) unless the data is truly static - Subscribers receive notifications when the cache is invalidated, allowing them to trigger a fresh fetch if needed ### API Usage Layering Data fetching in BanGUI follows a strict, composable three-tier pattern to ensure consistency and testability: **Tier 1: API Functions** (`api/*.ts`) - Pure, typed wrappers around HTTP calls to backend endpoints - Accept an optional `signal?: AbortSignal` for request cancellation (GET only) - Never manage state, handle errors, or retry logic - Example: `fetchBans(range, page, pageSize, origin, source, signal)` returns typed data **Tier 2: Reusable Generic Hooks** (`hooks/useListData.ts`, `hooks/useConfigItem.ts`) - Provide common state management patterns: fetch + abort + error handling + refresh - Accept a `fetcher` function (Tier 1 API function) and a `selector` (to extract data from response) - Return structured results: `{ data, loading, error, refresh }` - Use for: lists, single-item configs, paginated data — any fetch-and-display pattern - Automatically abort in-flight requests on unmount - Example: `useListData({ fetcher: (signal) => fetchBans(..., signal), selector: (res) => res.items })` **Tier 3: Domain Hooks** (`hooks/useBans.ts`, `hooks/useActiveBans.ts`, etc.) - Compose Tier 2 generic hooks and add domain-specific actions - Manage domain state (e.g., `page`, `total`), expose action callbacks (`banIp`, `unbanIp`) - Return a domain-specific result shape (e.g., `{ banItems, total, page, setPage, banIp, unbanIp, ... }`) - Called by pages to feed state to components or context providers - Example: ```ts const fetcher = useCallback((signal) => fetchBans(timeRange, page, ..., signal), [timeRange, page]); const { items: banItems, ... } = useListData({ fetcher, selector: (res) => res.items }); return { banItems, total, banIp: doBan, ... }; ``` **Tier 4: Components** (`components/*.tsx`, `pages/*.tsx`) - Never call API functions or Tier 1 functions directly - Receive data and actions via **props or context** — never create hooks themselves - Emit changes via callbacks: `onClick={() => props.onBan(jail, ip)}` - Remain presentational and fully testable without backend mocks **Pattern for action callbacks with data refresh:** When a component action needs to update the displayed list, have the domain hook refresh automatically: ```ts const doBan = useCallback( async (jail: string, ip: string): Promise => { await banIp(jail, ip); // Tier 1 API call refresh(); // Re-fetch the list from Tier 2 }, [refresh], ); ``` **When to use Tier 2 vs Tier 3:** - Use `useListData` / `useConfigItem` for any new data-fetching scenario that matches the pattern - Only write Tier 3 hooks when you need domain-specific logic (actions, computed values, complex state) - Avoid duplicating Tier 3 hooks for identical patterns — refactor to a shared Tier 2 generic instead **Anti-patterns to avoid:** - ❌ Components calling `fetchBans()` directly — violates Tier 4 rule - ❌ Tier 3 hooks managing all state inline instead of using Tier 2 generics — reduces reusability - ❌ Passing API functions directly to components — couples components to API contract - ❌ Multiple domain hooks for the same data without deduplication — causes wasted requests and state desync ### Standardized Pagination All paginated endpoints follow a consistent contract to simplify frontend logic and reduce boilerplate: **Pagination Response Type:** ```ts // types/response.ts export interface PaginatedListResponse extends CollectionResponse { /** Current page number (1-based). */ page: number; /** Number of items per page. */ page_size: number; } // Extending interfaces for specific data types: // types/ban.ts export interface DashboardBanListResponse extends PaginatedListResponse {} // types/history.ts export interface HistoryListResponse extends PaginatedListResponse {} // types/blocklist.ts export interface ImportLogListResponse extends PaginatedListResponse {} ``` **Query Parameters:** All paginated endpoints accept these standardized parameters: | Parameter | Type | Range | Default | |---|---|---|---| | `page` | number | ≥ 1 | `1` | | `page_size` | number | 1–500 | `100` | **Frontend Calculation of Total Pages:** Frontend code should calculate total pages from the response without relying on a `total_pages` field: ```ts // Compute the total number of pages const totalPages = Math.ceil(response.total / response.page_size); const isLastPage = response.page >= totalPages; const isFirstPage = response.page === 1; // Determine next/previous pages (with bounds checking) const nextPage = isLastPage ? response.page : response.page + 1; const prevPage = isFirstPage ? 1 : response.page - 1; ``` **Example Hook:** ```ts // hooks/useHistoryPagination.ts export function useHistoryPagination() { const [page, setPage] = useState(1); const pageSize = 100; const fetcher = useCallback( (signal: AbortSignal) => fetchHistory( { page, pageSize, /* filters */ }, signal, ), [page, pageSize], ); const { items: historyItems, data: fullResponse, loading, error, refresh } = useListData({ fetcher, selector: (res: HistoryListResponse) => res.items, }); // Calculate pagination UI state const totalPages = Math.ceil((fullResponse?.total ?? 0) / pageSize); const canGoPrevious = page > 1; const canGoNext = page < totalPages; return { historyItems, page, pageSize, total: fullResponse?.total ?? 0, totalPages, canGoPrevious, canGoNext, setPage: (newPage: number) => setPage(Math.max(1, Math.min(newPage, totalPages))), loading, error, refresh, }; } ``` ### Hook Architecture & Reusable Primitives BanGUI's hooks are built on composable primitives to eliminate duplication and enforce consistent patterns. **Base Hook: `useFetchData`** (`hooks/useFetchData.ts`) - The foundation of all data-fetching hooks - Encapsulates fetch lifecycle: abort controller management, loading/error state, cancellation safety - Signature: `useFetchData(fetcher, selector, errorMessage, onSuccess?, initialData?) → { data, loading, error, refresh }` - Not used directly by consumers; only composed by higher-level hooks - Handles automatic cleanup on unmount (abort signal cancellation) **Tier 2 Hooks Built on `useFetchData`:** - `useListData`: Wraps `useFetchData` with `initialData` defaulting to `[]` and returns `{ items, ... }` - `usePolledData`: Wraps `useFetchData` and adds polling (interval) + window-focus refetch on top - Additional specialized hooks can be added by composing `useFetchData` with domain-specific effects **Composition Pattern for New Hooks:** When building a new Tier 2 hook with custom behavior, follow this pattern: ```ts export function useMyCustomData(options: MyOptions): MyResult { // 1. Use useFetchData for the base fetch lifecycle const { data, loading, error, refresh } = useFetchData({ fetcher: options.fetcher, selector: options.selector, errorMessage: options.errorMessage, onSuccess: options.onSuccess, initialData: options.initialData, }); // 2. Add custom effects for additional behavior (e.g., polling, focus handling, custom cleanup) useEffect(() => { // Your custom logic here }, [...dependencies]); // 3. Return a domain-specific result shape return { data, loading, error, refresh, customField: /* derived */ }; } ``` **Why this architecture?** - **DRY**: Eliminates duplicate fetch logic across multiple hooks - **Consistency**: All hooks share the same cancellation and error handling semantics - **Testability**: Base hook can be tested in isolation; custom effects are minimal and easy to test - **Maintainability**: Bug fixes to abort or error handling only need to happen once ### Request Lifecycle & Navigation-Aware Cancellation BanGUI provides a global cancellation mechanism that automatically aborts route-specific requests when the user navigates away. This prevents: - Silent errors from responses arriving after component unmount - Wasted bandwidth from now-irrelevant requests - State inconsistencies between pages **How It Works:** The `NavigationCancellationProvider` (wraps the entire router) detects route changes and aborts all `AbortSignal`s obtained from `useNavigationAbortSignal()`. Each route gets its own set of signals that live for the duration of that route. **When to Use Navigation Signals:** Use `useNavigationAbortSignal()` for data fetches that are **specific to the current route**: - Page-level data fetches (e.g., dashboard stats on the home page) - User-initiated refetches on the current page - Paginated lists, search results, filtered data **When NOT to Use:** Long-lived background tasks should manage their own lifecycle and NOT use navigation signals: - Session validation (survives route changes) - Service-level polling (e.g., server health checks) - Background syncs that may take longer than a user's stay on a page - Cross-route background operations **Usage Pattern:** ```ts // hooks/useDashboardData.ts export function useDashboardData(): DashboardResult { const signal = useNavigationAbortSignal(); // Gets signal for current route const { items: dashStats } = useListData({ fetcher: (sig) => fetchDashboardStats(sig || signal), // Use both signals selector: (res) => res.stats, errorMessage: "Failed to load dashboard stats", }); return { dashStats }; } ``` When the user navigates away, the signal is automatically aborted, cancelling any in-flight dashboard stats requests. **Opt-Out for Long-Lived Tasks:** If a fetch should persist across route changes, do not use `useNavigationAbortSignal()`. Instead, manage your own `AbortController`: ```ts // Service-level polling — persists across route changes export function useServerHealth(): HealthResult { const [health, setHealth] = useState(null); const controllerRef = useRef(new AbortController()); useEffect(() => { const poll = async () => { try { const data = await fetchHealth(controllerRef.current.signal); setHealth(data); } catch (err) { if (!(err instanceof DOMException && err.name === "AbortError")) { console.error(err); } } }; const interval = setInterval(poll, 5000); void poll(); // First fetch immediately return () => { clearInterval(interval); controllerRef.current?.abort(); }; }, []); return { health }; } ``` **Provider Configuration:** The `NavigationCancellationProvider` is automatically placed in `App.tsx` inside `BrowserRouter` but before `AuthProvider`. It wraps all routes (including setup and login) to ensure consistent cancellation behavior across the entire app. See `src/providers/PROVIDER_ORDER.md` for the full provider hierarchy and dependencies. --- ## 4. Code Organization ### Project Structure ``` frontend/ ├── public/ ├── src/ │ ├── api/ # API client, endpoint definitions, per-domain request files │ ├── assets/ # Static images, fonts, icons │ ├── components/ # Reusable UI components (buttons, modals, tables, etc.) │ ├── hooks/ # Custom React hooks │ ├── layouts/ # Page-level layout wrappers (sidebar, header, etc.) │ ├── pages/ # Route-level page components (one per route) │ ├── providers/ # React context providers (auth, theme, etc.) │ ├── theme/ # Fluent UI custom theme, tokens, and overrides │ ├── types/ # Shared TypeScript type definitions │ ├── utils/ # Pure helper functions, constants, formatters │ ├── App.tsx # Root component, FluentProvider + router setup │ ├── main.tsx # Entry point │ └── vite-env.d.ts # Vite type shims ├── .eslintrc.cjs ├── .prettierrc ├── tsconfig.json ├── vite.config.ts # Dev proxy: /api → http://backend:8000 (service DNS) └── package.json ``` > **Dev proxy target:** `vite.config.ts` proxies all `/api` requests to > `http://backend:8000` by default. Set `VITE_BACKEND_URL` in `frontend/.env` > or your shell to override the backend address for local development outside > Docker. > > Use the compose **service name** (`backend`), not `localhost` — inside the > container network `localhost` resolves to the frontend container itself and > causes `ECONNREFUSED`. ### Pages vs Components The distinction between **`pages/`** and **`components/`** is fundamental to the project structure: - **`pages/`** contains route-level entry point components — exactly **one component per route**. Pages map directly to URL paths (e.g., `JailDetailPage.tsx` → `/jail/:name`). Pages orchestrate the layout and compose multiple components, but contain **no reusable UI logic**. Pages should rarely be reused. - **`components/`** contains **reusable UI building blocks** — anything that could plausibly be used on multiple pages or in multiple contexts. This includes: - Presentation components (Button wrappers, Cards, custom form fields, data tables) - Feature sub-sections (e.g., `JailInfoSection`, `BannedIpsSection` — components that render a logical grouping of related UI within a page) - Modals, dialogs, popovers - Complex, stateful UI patterns **Rule of thumb:** If a component is only ever used on a single page, it **still belongs in `components/`** if it represents a coherent, self-contained piece of UI that could logically be reused on another page in the future. Pages are entry points; components are building blocks. **Example:** `BannedIpsSection` lives in `components/jail/` (not `pages/jail/`) because it is a reusable UI section that presents banned IPs. If a future report or dashboard also needed to show banned IPs, the same component could be imported and reused. By contrast, `JailDetailPage.tsx` lives in `pages/` because it is the top-level route component. ### Tab Orchestration — ConfigPage Example When a page contains tab-based navigation (like the configuration page), isolate routing and tab state management into a dedicated **container component** to prevent the page from becoming over-centralized. This pattern applies to any multi-tab page. **Architecture:** 1. **Page component** (`ConfigPage.tsx`) — renders page layout (header, title, description) and delegates tab routing to a container. 2. **Container component** (`ConfigPageContainer.tsx`) — orchestrates tab navigation, manages which tab content is visible, and routes tab selection events. 3. **Tab router hook** (`useTabRouter.ts`) — encapsulates tab state synchronization with browser history and supports deep linking (e.g., navigating directly to a specific tab with optional active item like a jail name). 4. **Tab components** (`JailsTab.tsx`, `FiltersTab.tsx`, etc.) — domain-specific tab content; each is fully self-contained and receives tab-specific props only. **Component tree:** ``` ConfigPage (page layout) └── ConfigPageContainer (tab orchestration) ├── useTabRouter (routing logic) ├── JailsTab (jail editing UI) ├── FiltersTab (filter editing UI) ├── ActionsTab (action editing UI) ├── ServerTab (server settings UI) └── RegexTesterTab (regex testing UI) ``` **Benefits:** - **Focused pages** — `ConfigPage` renders only layout; routing logic is in the container. - **Reusable routing** — `useTabRouter` can be used by other pages with tab navigation. - **Isolated tabs** — each tab is a focused component; no shared state entanglement. - **Deep linking** — tab state is synchronized to browser history, allowing bookmarkable URLs and the back/forward buttons to work correctly. **Key pattern:** ```tsx // hooks/useTabRouter.ts — routes and state export type ConfigTabId = "jails" | "filters" | "actions" | "server" | "regex"; export function useTabRouter(): { activeTab: ConfigTabId; selectTab: (tab: ConfigTabId) => void; ... } { const location = useLocation(); const navigate = useNavigate(); // Sync tab state to location.state for deep linking // ... (see implementation) } // components/config/ConfigPageContainer.tsx — renders tabs export function ConfigPageContainer(): JSX.Element { const { activeTab, selectTab } = useTabRouter(); return ( <> {/* Tabs */} {/* Tab content panels, conditionally rendered via CSS */} ); } // pages/ConfigPage.tsx — layout only export function ConfigPage(): JSX.Element { return (
{/* Page header, title, description */}
); } ``` **Avoid:** - Mixing page layout, tab orchestration, and tab content in one file. - Duplicating tab state across multiple hooks or components. - Hardcoding tab IDs as strings — use the `ConfigTabId` type. ### Separation of Concerns - **Pages** handle routing and compose layout + components — they contain no business logic. - **Components** are reusable, receive data via props, and emit changes via callbacks — they never call the API directly. - **Hooks** encapsulate stateful logic, side effects, and API calls so components stay declarative. - **API layer** handles all HTTP communication — components and hooks consume typed functions from `api/`, never raw `fetch`. - **Types** are purely declarative — no runtime code in `types/` files. - **Utils** are pure functions with no side effects and no React dependency. - **Theme** contains exclusively Fluent UI custom token overrides and theme definitions — no component logic. ### Providers — App-Wide vs Page-Scoped The `providers/` directory is reserved for **app-wide context providers** — providers that wrap the entire application or large sections of it and are used by many pages or components. **App-wide providers belong in `providers/`:** - `AuthProvider` — authentication state for the whole app - `ThemeProvider` — theme/styling state for the whole app - `TimezoneProvider` — timezone preference for the whole app **Page-scoped providers belong co-located with their consumer:** If a React Context provider is used by **only one page** (e.g., `DashboardFilterProvider` is used only by `DashboardPage`), it should live **in the same directory as the page** or in a subdirectory alongside the page's components. This prevents the `providers/` directory from being cluttered with page-specific state and makes the scope of these providers clear to future contributors. **Example:** `DashboardFilterProvider` manages dashboard time-range and origin filters. It is instantiated only inside `DashboardPage.tsx` and its sub-components. Therefore, it lives in `pages/DashboardFilterProvider.tsx` (or `pages/dashboard/DashboardFilterProvider.tsx` if the page is split into a subdirectory), not in `providers/`. ### State Ownership & Prop Drilling When a page uses a hook (e.g., `useJails()`) that provides state and actions, and this state needs to be accessed by multiple child components, **eliminate prop drilling by wrapping the page in a context provider**. This reduces coupling, simplifies refactoring, and keeps prop lists focused on component-specific data. **When to use context instead of props:** - A page calls a hook that returns both data and actions (e.g., `useJails()` returns `jails`, `loading`, `refresh`, `startJail`, etc.) - Two or more child components need access to the same hook's state - The prop chain would be longer than 2 levels deep **Pattern:** 1. Create a context and provider in the same directory as the page's components (e.g., `pages/jails/JailContext.tsx`) 2. Wrap the page content with the provider, passing the hook's result as the context value 3. Child components use the context hook instead of receiving props 4. Update tests to wrap components with the provider **Example:** ```tsx // pages/jails/JailContext.tsx const JailContext = createContext(undefined); export function JailProvider({ children }: { children: ReactNode }): JSX.Element { const jailState = useJails(); return {children}; } export function useJailContext(): JailContextValue { const context = useContext(JailContext); if (!context) throw new Error("useJailContext must be used within JailProvider"); return context; } // pages/JailsPage.tsx export function JailsPage(): JSX.Element { return ( {/* Uses useJailContext() */} ); } ``` **Important:** Context should only wrap the minimal subtree that needs access. Do not wrap the entire app with page-specific contexts — keep providers scoped to where they're used. --- ## 5. UI Framework — Fluent UI React (v9) - **Fluent UI React Components v9** (`@fluentui/react-components`) is the only UI component library allowed — do not add alternative component libraries (Material UI, Chakra, Ant Design, etc.). - Install via npm: ```bash npm install @fluentui/react-components @fluentui/react-icons ``` ### FluentProvider - Wrap the entire application in `` at the root — this supplies the theme and design tokens to all Fluent components. - The provider must sit above the router so every page inherits the theme. ```tsx // App.tsx import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { BrowserRouter } from "react-router-dom"; import AppRoutes from "./AppRoutes"; function App(): JSX.Element { return ( ); } export default App; ``` ### Theming & Design Tokens - Use the built-in themes (`webLightTheme`, `webDarkTheme`) as the base. - Customise design tokens by creating a **custom theme** in `theme/` — never override Fluent styles with raw CSS. - Reference tokens via the `tokens` object from `@fluentui/react-components` when writing `makeStyles` rules. - If light/dark mode is needed, switch the `theme` prop on `FluentProvider` — never duplicate style definitions for each mode. ```ts // theme/customTheme.ts import { createLightTheme, createDarkTheme } from "@fluentui/react-components"; import type { BrandVariants, Theme } from "@fluentui/react-components"; const brandColors: BrandVariants = { 10: "#020305", // ... define brand colour ramp 160: "#e8ebf9", }; export const lightTheme: Theme = createLightTheme(brandColors); export const darkTheme: Theme = createDarkTheme(brandColors); ``` ### Styling with `makeStyles` (Griffel) - All custom styling is done via `makeStyles` from `@fluentui/react-components` — Fluent UI uses **Griffel** (CSS-in-JS with atomic classes) under the hood. - Never use inline `style` props, global CSS, or external CSS frameworks for Fluent components. - Co-locate styles in the same file as the component they belong to, defined above the component function. - Use `mergeClasses` when combining multiple style sets conditionally. - Reference Fluent **design tokens** (`tokens.colorBrandBackground`, `tokens.fontSizeBase300`, etc.) instead of hard-coded values — this ensures consistency and automatic theme support. - **Inline styles are only allowed for genuinely dynamic values** (e.g., tooltip position calculated from mouse position, or height derived from data count). All static layout properties (`display`, `gap`, `margin`, `padding`, colour) must go in `makeStyles`. ```tsx import { makeStyles, tokens, mergeClasses } from "@fluentui/react-components"; const useStyles = makeStyles({ root: { padding: tokens.spacingVerticalM, backgroundColor: tokens.colorNeutralBackground1, }, highlighted: { backgroundColor: tokens.colorPaletteRedBackground2, }, }); function BanCard({ isHighlighted }: BanCardProps): JSX.Element { const styles = useStyles(); return (
{/* ... */}
); } ``` ### Component Usage Rules - **Always** prefer Fluent UI components over plain HTML elements for interactive and presentational UI: ` ); } // After: Using notification service function MyForm() { const notification = useNotification(); const handleSubmit = async () => { try { await saveData(); notification.success("Data saved!"); } catch (err) { notification.error("Failed to save"); } }; return ; } ``` **Using notifications with hooks:** If a hook manages data fetching and needs to notify of errors, accept a notification callback or use `useNotification` directly within the hook: ```ts // hooks/useSaveData.ts export function useSaveData() { const notification = useNotification(); const [loading, setLoading] = useState(false); const save = useCallback(async (data: unknown) => { setLoading(true); try { await api.post("/data", data); notification.success("Data saved"); return true; } catch (err) { notification.error("Failed to save data"); return false; } finally { setLoading(false); } }, [notification]); return { save, loading }; } ``` ### API Error Handling - Wrap API calls in `try-catch` inside hooks — components should never see raw exceptions. - **All hook catch blocks should use `handleFetchError` or notifications.** This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI. - **With local state:** `handleFetchError(err, setError, "User-friendly fallback message")` - **With notifications:** `handleFetchError(err, setError, "Default message", notification.error)` - Display user-friendly error messages — never expose stack traces or raw server responses in the UI. - Log errors to the console (or a future logging service) with sufficient context for debugging. - Always handle the **loading**, **error**, and **empty** states for every data-driven component. ### Typed Error Model All API errors are normalized into a discriminated `FetchError` union type. This replaces generic error strings with **actionable, typed error information** that enables better diagnostics and UX. **The three error types:** ```ts // From src/types/api.ts // Server returned HTTP error (non-2xx status) interface ApiErrorPayload { type: "api_error"; status: number; // 401, 403, 500, etc. body: string; // Raw response body message: string; // Formatted for display } // Network failure, DNS lookup failure, JSON parse error, etc. interface NetworkErrorPayload { type: "network_error"; message: string; } // Request was cancelled (component unmount, user abort, etc.) interface AbortErrorPayload { type: "abort_error"; message: string; } // Union of all three type FetchError = ApiErrorPayload | NetworkErrorPayload | AbortErrorPayload; ``` **Usage in hooks:** ```ts import { useListData } from "./useListData"; import type { FetchError } from "../types/api"; import { isAuthError, isApiError } from "../types/api"; export function useBans(): UseBansResult { const { items, loading, error, refresh } = useListData({ fetcher, selector, errorMessage: "Failed to load bans", }); // error is now FetchError | null, not string | null // Use the type discriminator to handle different cases: if (error?.type === "api_error") { if (error.status === 401 || error.status === 403) { // Already handled globally by AuthProvider, but you can check specifically } else if (error.status >= 500) { // Server error — suggest retry or contact support } } else if (error?.type === "network_error") { // Network connectivity issue — show offline-friendly message } else if (error?.type === "abort_error") { // Request was cancelled — typically silent (component unmounted) } return { items, loading, error, refresh }; } ``` **Extracting displayable messages:** ```ts import { getErrorMessage } from "../utils/fetchError"; // Convert any FetchError to a user-friendly string: if (error) { const message = getErrorMessage(error); // Abort and auth errors return empty string (silently handled) // Other errors return error.message if (message) { showNotification(message); } } ``` **Backward compatibility with string-based error state:** If a component still uses `useState` for errors, use the adapter: ```ts import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError"; const [errorMessage, setErrorMessage] = useState(null); fetch() .catch((err: unknown) => { // Adapt the FetchError to string setter handleFetchError(err, createStringErrorAdapter(setErrorMessage), "Failed"); }); ``` ### Error Boundaries — Granular Fallback Strategy React error boundaries catch render-time exceptions and allow graceful fallback UI instead of a full white screen crash. BanGUI implements a **three-level error boundary strategy** to balance resilience with UX clarity: #### Top-Level Boundary (``) - Wraps the entire application in `App.tsx` - Catches critical failures in auth, theming, or routing infrastructure - Shows a full-page fallback with reload button - **Use case:** Rare catastrophic failures; most errors should be caught at lower levels #### Page-Level Boundary (``) - Wraps each route in `App.tsx` (Dashboard, Map, Jails, Config, History, Blocklists, etc.) - Catches render errors in page components and their children - Shows a page-level fallback but preserves app shell (sidebar navigation stays functional) - User can still navigate away via the sidebar and retry the page - **Use case:** Page component crashes (component tree errors, unhandled render-time exceptions) **Example:** ```tsx } /> ``` #### Section-Level Boundary (``) - Wraps individual data-heavy components within a page (charts, tables, forms) - Examples: `BanTrendChart`, `TopCountriesBarChart`, `BanTable`, `JailOverviewSection` - Shows a section-level fallback card but the rest of the page remains functional - User can retry just that section or interact with other sections - **Use case:** Component-specific data fetching errors, rendering issues in risky components **Example:** ```tsx
Ban Trend
``` ### When to Use Each Boundary - **Page boundaries:** Always wrap page routes in `App.tsx` (`Dashboard`, `Map`, `Jails`, etc.) - **Section boundaries:** Wrap risky components that fetch data or have complex side effects - Data visualizations (charts) - Data tables and lists - Complex forms - Components using external libraries (D3, Canvas, etc.) - **Top-level boundary:** Leave as-is; only modify if auth/routing infrastructure changes ### Boundary Best Practices - Do not over-use boundaries — too many nested boundaries can confuse error UX - Ensure section fallback UI doesn't disrupt page layout (use consistent sizing/spacing) - Provide meaningful error titles and messages (`pageName` and `sectionName` props) - Retry buttons allow users to recover from transient failures without page reload - Consider logging errors via `onError` callback for debugging and monitoring --- ## 13. Performance - Use `React.memo` only when profiling reveals unnecessary re-renders — do not wrap every component by default. - Use `useMemo` and `useCallback` for expensive computations and stable callback references passed to child components — not for trivial values. - Lazy-load route-level pages with `React.lazy` + `Suspense` to reduce initial bundle size. - Avoid creating new objects or arrays inside render unless necessary — stable references prevent child re-renders. - Keep bundle size in check — review dependencies before adding them and prefer lightweight alternatives. --- ## 14. Accessibility - Use semantic HTML elements (`