Persist sidebar collapsed preference to localStorage

This commit is contained in:
2026-04-21 19:17:00 +02:00
parent b3eb5dc6ec
commit 4f91e8fdd3
3 changed files with 53 additions and 7 deletions

View File

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

View File

@@ -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<boolean>(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(() => {

View File

@@ -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");
});
});