diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 7b30165..7bd9d58 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -415,7 +415,9 @@ const source = timeRange === "24h" ? "fail2ban" : "archive"; --- -### TASK-021 — Persist sidebar collapse preference to `localStorage` +### TASK-021 — Persist sidebar collapse preference to `localStorage` (done) + +**Where fixed:** `frontend/src/layouts/MainLayout.tsx` **Where found:** `frontend/src/layouts/MainLayout.tsx` — `useState(() => window.innerWidth < 640)` initialises collapsed state but never saves the user's toggle preference. diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 88799fd..5501cae 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -40,6 +40,7 @@ import { useBlocklistStatus } from "../hooks/useBlocklist"; const SIDEBAR_FULL = "240px"; const SIDEBAR_COLLAPSED = "48px"; +const SIDEBAR_COLLAPSED_STORAGE_KEY = "bangui_sidebar_collapsed"; const useStyles = makeStyles({ root: { @@ -220,23 +221,52 @@ export function MainLayout(): React.JSX.Element { const styles = useStyles(); const { logout } = useAuth(); const navigate = useNavigate(); - // Initialise collapsed based on screen width so narrow viewports start - // with the icon-only sidebar rather than the full-width one. - const [collapsed, setCollapsed] = useState(() => window.innerWidth < 640); + + const readSavedCollapsed = (): boolean => { + try { + const savedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY); + if (savedValue === "true") { + return true; + } + if (savedValue === "false") { + return false; + } + } catch { + // Ignore storage errors and fall back to viewport heuristics. + } + return window.innerWidth < 640; + }; + + const [collapsed, setCollapsed] = useState(readSavedCollapsed); const { status } = useServerStatus(); const { hasErrors: blocklistHasErrors } = useBlocklistStatus(); /** True only after the first successful poll and fail2ban is unreachable. */ const serverOffline = status !== null && !status.online; - // Auto-collapse / auto-expand when the viewport crosses the 640 px breakpoint. useEffect(() => { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Local storage may be unavailable in some environments. + } + }, [collapsed]); + + useEffect(() => { + const savedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY); + if (savedValue !== null) { + return undefined; + } + const mq = window.matchMedia("(max-width: 639px)"); const handler = (e: MediaQueryListEvent): void => { setCollapsed(e.matches); }; + mq.addEventListener("change", handler); - return (): void => { mq.removeEventListener("change", handler); }; + return (): void => { + mq.removeEventListener("change", handler); + }; }, []); const toggleCollapse = useCallback(() => { diff --git a/frontend/src/layouts/__tests__/MainLayout.test.tsx b/frontend/src/layouts/__tests__/MainLayout.test.tsx index d24787b..ca53288 100644 --- a/frontend/src/layouts/__tests__/MainLayout.test.tsx +++ b/frontend/src/layouts/__tests__/MainLayout.test.tsx @@ -18,7 +18,7 @@ import { MainLayout } from "../../layouts/MainLayout"; // Mocks // --------------------------------------------------------------------------- -vi.mock("../../providers/AuthProvider", () => ({ +vi.mock("../../hooks/useAuth", () => ({ useAuth: () => ({ logout: vi.fn() }), })); @@ -56,6 +56,7 @@ function renderLayout(): void { describe("MainLayout", () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); }); it("renders the navigation sidebar", () => { @@ -75,4 +76,17 @@ describe("MainLayout", () => { await userEvent.click(toggleButton); expect(screen.queryByText("BanGUI")).not.toBeInTheDocument(); }); + + it("initialises sidebar collapsed state from localStorage", () => { + localStorage.setItem("bangui_sidebar_collapsed", "true"); + renderLayout(); + expect(screen.queryByText("BanGUI")).not.toBeInTheDocument(); + }); + + it("persists sidebar collapsed state to localStorage when toggled", async () => { + renderLayout(); + const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i }); + await userEvent.click(toggleButton); + expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true"); + }); });