import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { NavItem } from "../modules/types.ts"; import { Link } from "react-router"; import type { Ref } from "react"; import { clearToken } from "../auth/token.ts"; import { useActivitySummary } from "./activity/activity.ts"; import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx"; import { GROUPS, OVERVIEW } from "./nav.tsx"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; import { readSidebar, saveSidebar } from "./sidebarPref.ts"; export type Section = string; // узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4) const NARROW = "(max-width: 639px)"; const subscribe = (cb: () => void) => { if (typeof matchMedia !== "function") return () => {}; const m = matchMedia(NARROW); m.addEventListener?.("change", cb); return () => m.removeEventListener?.("change", cb); }; const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches; type Props = { section: Section | null; project: string | null }; // боковая панель (#69, design 1–4); section = null на путях вне разделов — активной ссылки нет export function Sidebar(p: Props) { const narrow = useSyncExternalStore(subscribe, isNarrow); return narrow ? : ; } const SUMMARY_TEXT = { error: "ошибка", busy: "идёт обновление", online: "агент на связи", offline: "агент офлайн" } as const; // сводная точка состояния модулей: цвет не единственный признак — состояние в aria-label и title function SummaryDot() { const summary = useActivitySummary(); const text = `Состояние модулей: ${SUMMARY_TEXT[summary]}`; return ; } function NarrowShell(p: Props) { const [open, setOpen] = useState(false); const menuBtn = useRef(null); // объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке const drawer = useMemo(() => ({ close: () => setOpen(false), navigated: () => setOpen(false) }), []); // фокус возвращается на «Меню» после закрытия: пока панель открыта, строка инертна и фокус на неё не встал бы const wasOpen = useRef(false); useEffect(() => { if (wasOpen.current && !open) menuBtn.current?.focus(); wasOpen.current = open; }, [open]); return ( <> setOpen(true)}> {p.project && {p.project}} {open && ( )} > ); } // содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, «Обзор» и разделы по модулям, внизу состояние // модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73) function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) { const qc = useQueryClient(); const [stored, setCollapsed] = useState(readSidebar); const collapsed = !drawer && stored; const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; const href = (to: string) => (project ? to + withProject("", project) : to); const first = useRef(null); useEffect(() => { if (!drawer) return; first.current?.focus(); const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); }; document.addEventListener("keydown", esc); // выезжающая панель модальна: страница и строка приложения недоступны с клавиатуры и для чтения с экрана const behind = [...document.querySelectorAll(".shell > :not(.sidebar-modal)")]; behind.forEach((el) => el.setAttribute("inert", "")); // и страница под подложкой не прокручивается (#73 M-2) const root = document.documentElement, overflow = root.style.overflow; root.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", esc); behind.forEach((el) => el.removeAttribute("inert")); root.style.overflow = overflow; }; }, [drawer]); const link = (s: NavItem, ref?: Ref) => ( {s.icon}{s.label} ); return ( ); }