// ============================================================================ // Persistent storage keys // ============================================================================ // STORAGE_KEY хранит пользовательские настройки интерфейса: // тему, акцент, режим отображения, ширину сайдбара и toggle-настройки. const STORAGE_KEY = "ecosystem-panel-preferences"; // COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details: // отдельно для дерева навигации и отдельно для контентных секций. const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state"; // ============================================================================ // Navigation data model // ============================================================================ // Меняй навигацию здесь: разделы -> подразделы -> элементы. // url можно заменить на реальные адреса, tags помогают поиску. // // Структура: // - section: крупный раздел панели; // - group: подраздел внутри section; // - item: конечный кликабельный элемент. // // Каждый item имеет: // - number: человекочитаемый номер; // - title: название карточки; // - type: ключ из serviceMeta, который задает иконку и тип сервиса; // - tags: поисковые теги, которые также можно показывать в дереве; // - url: ссылка перехода. По умолчанию "#", чтобы заглушки не дергали страницу. const ecosystemSections = [ { id: "user", number: "1", title: "Интерфейс пользователя", description: "Рабочие инструменты пользователя для фильтрации и обработки сообщений.", groups: [ { id: "message-filtering", number: "1.1", title: "Управление фильтрацией сообщений", description: "Правила, задания, события и статистика фильтрации.", items: [ createItem("1.1.1", "Редактирование правил", "rules", ["правила", "фильтрация", "редактор"]), createItem("1.1.2", "Управление заданиями", "redmine", ["redmine", "задачи", "задания"]), createItem("1.1.3", "Применение правил", "rules", ["правила", "применение", "фильтрация"]), createItem("1.1.4", "Просмотр статистики", "grafana", ["grafana", "статистика", "дашборд"]), createItem("1.1.5", "Управление событиями фильтрации", "grafana", ["grafana", "события", "алерты"]), createItem("1.1.6", "Просмотр журнала событий фильтрации", "grafana", ["grafana", "журнал", "события"]), ], }, { id: "message-processing", number: "1.2", title: "Обработка сообщений", description: "Просмотр, оформление и доступ к хранилищам сообщений.", items: [ createItem("1.2.1", "Просмотр и оформление", "messages", ["сообщения", "просмотр", "оформление"]), createItem("1.2.2", "Прямой доступ", "ceph", ["ceph", "доступ", "объекты"]), createItem("1.2.3", "S3 Хранилище", "ceph", ["ceph", "s3", "хранилище"]), ], }, ], }, { id: "admin", number: "2", title: "Интерфейс администратора", description: "Администрирование сервисов, кластеров, мониторинга и аппаратной части.", groups: [ { id: "service-monitoring", number: "2.1", title: "Управление и мониторинг сервисов", description: "Кластер, модули, события мониторинга и журналы ошибок.", items: [ createItem("2.1.1", "Управление кластером", "kubernetes", ["kubernetes", "кластер", "k8s"]), createItem("2.1.2", "Мониторинг состояния модулей", "grafana", ["grafana", "модули", "мониторинг"]), createItem("2.1.3", "Управление событиями мониторинга", "grafana", ["grafana", "события", "алерты"]), createItem("2.1.4", "Просмотр журнала событий мониторинга", "grafana", ["grafana", "журнал", "мониторинг"]), createItem("2.1.5", "Просмотр журналов ошибок", "opensearch", ["opensearch", "ошибки", "логи"]), ], }, { id: "service-configuration", number: "2.2", title: "Настройка сервисов", description: "Пользователи, очереди, xPath, модули и конфигурации.", items: [ createItem("2.2.1", "Управление пользователями и очередями", "rabbitmq", ["пользователи", "очереди", "rabbitmq"]), createItem("2.2.2", "Отладка xPath-запросов", "xpath", ["xpath", "отладка", "запросы"]), createItem("2.2.3", "Конфигурирование модулей", "config", ["конфигурация", "модули", "настройки"]), createItem("2.2.4", "Просмотр журнала конфигурирования", "gitea", ["gitea", "журнал", "конфигурация"]), createItem("2.2.5", "Прямой доступ к конфигурации", "gitea", ["gitea", "конфигурация", "git"]), ], }, { id: "hardware", number: "2.3", title: "Управление и мониторинг аппаратной части", description: "Сенсоры, ресурсы и прямые панели серверов.", items: [ createItem("2.3.1", "Мониторинг показаний сенсоров", "grafana", ["grafana", "сенсоры", "железо"]), createItem("2.3.2", "Мониторинг потребления ресурсов", "grafana", ["grafana", "ресурсы", "метрики"]), createItem("2.3.3", "СЕРВЕР01", "ipmi", ["ipmi", "сервер01", "железо"]), createItem("2.3.4", "СЕРВЕР02", "ipmi", ["ipmi", "сервер02", "железо"]), createItem("2.3.5", "СЕРВЕР03", "ipmi", ["ipmi", "сервер03", "железо"]), createItem("2.3.6", "СЕРВЕР04", "ipmi", ["ipmi", "сервер04", "железо"]), createItem("2.3.7", "СЕРВЕР05", "ipmi", ["ipmi", "сервер05", "железо"]), ], }, { id: "service-panels", number: "2.4", title: "Настройка отдельных сервисов", description: "Панели управления инфраструктурными сервисами.", items: [ createItem("2.4.1", "Панель управления LDAP Account Manager", "ldap", ["ldap", "accounts", "пользователи"]), createItem("2.4.2", "Панель управления кластера RabbitMQ", "rabbitmq", ["rabbitmq", "кластер", "очереди"]), createItem("2.4.3", "Панель управления кластера Ceph", "ceph", ["ceph", "кластер", "storage"]), createItem("2.4.4", "Панель управления Prometheus", "prometheus", ["prometheus", "метрики", "мониторинг"]), createItem("2.4.5", "Панель управления XMPP-сервера", "xmpp", ["xmpp", "чат", "сервер"]), createItem("2.4.6", "Панель управления PostgreSQL", "postgresql", ["postgresql", "база", "db"]), createItem("2.4.7", "Подключение к БД", "cloudbeaver", ["cloudbeaver", "database", "sql"]), ], }, { id: "packages", number: "2.5", title: "Образы и пакеты", description: "Реестры образов и deb-пакетов.", items: [ createItem("2.5.1", "Реестр docker-образов", "gitea", ["gitea", "docker", "registry"]), createItem("2.5.2", "Репозиторий deb-пакетов", "gitea", ["gitea", "deb", "packages"]), ], }, ], }, ]; // Метаданные типов сервисов. // item.type ссылается на ключ здесь: так карточки получают label и icon. const serviceMeta = { ceph: { label: "Ceph", icon: "assets/ceph.svg" }, cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" }, config: { label: "Config", icon: "assets/config.svg" }, gitea: { label: "Gitea", icon: "assets/gitea.svg" }, grafana: { label: "Grafana", icon: "assets/grafana.svg" }, ipmi: { label: "IPMI", icon: "assets/server.svg" }, kubernetes: { label: "Kubernetes", icon: "assets/kubernetes.svg" }, ldap: { label: "LDAP", icon: "assets/ldap.svg" }, messages: { label: "Messages", icon: "assets/messages.svg" }, opensearch: { label: "OpenSearch", icon: "assets/opensearch.svg" }, postgresql: { label: "PostgreSQL", icon: "assets/database.svg" }, prometheus: { label: "Prometheus", icon: "assets/prometheus.svg" }, rabbitmq: { label: "RabbitMQ", icon: "assets/rabbitmq.svg" }, redmine: { label: "Redmine", icon: "assets/redmine.svg" }, rules: { label: "Rules", icon: "assets/rules.svg" }, xpath: { label: "xPath", icon: "assets/xpath.svg" }, xmpp: { label: "XMPP", icon: "assets/xmpp.svg" }, }; // ============================================================================ // Default UI preferences // ============================================================================ // Значения используются при первом открытии или если localStorage пуст/битый. const defaultPreferences = { theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", view: "cards", accent: "violet", showNumbers: false, showTreeTags: true, autoCollapseSearch: true, sidebarWidth: 390, }; const availableViews = ["cards", "list", "compact-cards", "compact-list"]; // ============================================================================ // Runtime state and DOM references // ============================================================================ // preferences - текущие настройки интерфейса, объединенные с localStorage. const preferences = { ...defaultPreferences, ...readPreferences() }; // collapseState - объект вида { [collapseKey]: boolean } для details. const collapseState = readCollapseState(); // Корневые DOM-узлы, которые нужны для рендера и событий. const catalog = document.querySelector("[data-catalog-root]"); const navTree = document.querySelector("[data-nav-tree]"); const sidebarResizer = document.querySelector("[data-sidebar-resizer]"); const searchInput = document.querySelector("[data-search-input]"); const settingsToggle = document.querySelector("[data-settings-toggle]"); const settingsPanel = document.querySelector("[data-settings-panel]"); const settingsOverlay = document.querySelector("[data-settings-overlay]"); const settingsClose = document.querySelector("[data-settings-close]"); const themeButtons = document.querySelectorAll("[data-theme]"); const viewButtons = document.querySelectorAll("[data-view]"); const accentButtons = document.querySelectorAll("[data-accent]"); const showNumbersInput = document.querySelector("[data-show-numbers]"); const showTreeTagsInput = document.querySelector("[data-show-tree-tags]"); const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]"); const emptyTemplate = document.querySelector("#empty-template"); // ============================================================================ // Initial render // ============================================================================ // Сначала строим дерево, затем применяем настройки, затем рендерим каталог. // Такой порядок нужен, потому что applyPreferences выставляет data-атрибуты // и CSS-переменные, от которых зависит внешний вид уже созданного DOM. renderNavigationTree(); applyPreferences(preferences); renderCatalog(); // ============================================================================ // Event bindings // ============================================================================ // Поиск перерисовывает и дерево, и каталог: // - дерево получает подсветку совпадений и авто-раскрытие веток; // - каталог фильтруется и тоже подсвечивает найденный текст. searchInput.addEventListener("input", () => { renderNavigationTree(); renderCatalog(); }); // Клик по файлу/папке в дереве фокусирует соответствующую секцию/карточку. // Само раскрытие details остается на нативном summary/caret. navTree.addEventListener("click", (event) => { const button = event.target.closest("[data-focus-target]"); if (!button) { return; } event.preventDefault(); event.stopPropagation(); focusCatalogTarget(button.dataset.focusTarget); }); // Сохраняем ручное раскрытие/сворачивание дерева и контента. // Используем capture=true, потому что toggle не всплывает как обычный click. navTree.addEventListener("toggle", handleDetailsToggle, true); catalog.addEventListener("toggle", handleDetailsToggle, true); // Открытие popup-настроек по шестеренке. settingsToggle.addEventListener("click", () => { const isOpen = settingsPanel.classList.toggle("is-open"); settingsOverlay.classList.toggle("is-open", isOpen); settingsToggle.setAttribute("aria-expanded", String(isOpen)); }); // Закрытие popup-настроек всеми привычными способами. settingsClose.addEventListener("click", closeSettingsPanel); settingsOverlay.addEventListener("click", closeSettingsPanel); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeSettingsPanel(); } }); document.addEventListener("click", (event) => { if (!settingsPanel.classList.contains("is-open")) { return; } if (settingsPanel.contains(event.target) || settingsToggle.contains(event.target)) { return; } closeSettingsPanel(); }); themeButtons.forEach((button) => { button.addEventListener("click", () => { preferences.theme = button.dataset.theme; applyPreferences(preferences); savePreferences(preferences); }); }); // Переключение режима отображения каталога. viewButtons.forEach((button) => { button.addEventListener("click", () => { preferences.view = button.dataset.view; applyPreferences(preferences); savePreferences(preferences); }); }); // Выбор цветового акцента. accentButtons.forEach((button) => { button.addEventListener("click", () => { preferences.accent = button.dataset.accent; applyPreferences(preferences); savePreferences(preferences); }); }); // Показывает/скрывает номера в секциях, группах и карточках. showNumbersInput.addEventListener("change", () => { preferences.showNumbers = showNumbersInput.checked; applyPreferences(preferences); savePreferences(preferences); }); // Показывает/скрывает поисковые теги в файловом дереве. showTreeTagsInput.addEventListener("change", () => { preferences.showTreeTags = showTreeTagsInput.checked; applyPreferences(preferences); savePreferences(preferences); }); // Включает/выключает авто-раскрытие веток дерева при поиске. autoCollapseSearchInput.addEventListener("change", () => { preferences.autoCollapseSearch = autoCollapseSearchInput.checked; applyPreferences(preferences); renderNavigationTree(); savePreferences(preferences); }); // Drag-resize бокового меню. // Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную. sidebarResizer.addEventListener("pointerdown", (event) => { event.preventDefault(); document.body.classList.add("is-resizing-sidebar"); sidebarResizer.setPointerCapture(event.pointerId); const handlePointerMove = (moveEvent) => { const layoutLeft = document.querySelector(".app-layout").getBoundingClientRect().left; preferences.sidebarWidth = Math.round(clamp(moveEvent.clientX - layoutLeft, 260, 560)); applyPreferences(preferences); }; const handlePointerUp = () => { document.body.classList.remove("is-resizing-sidebar"); savePreferences(preferences); sidebarResizer.removeEventListener("pointermove", handlePointerMove); sidebarResizer.removeEventListener("pointerup", handlePointerUp); sidebarResizer.removeEventListener("pointercancel", handlePointerUp); }; sidebarResizer.addEventListener("pointermove", handlePointerMove); sidebarResizer.addEventListener("pointerup", handlePointerUp); sidebarResizer.addEventListener("pointercancel", handlePointerUp); }); // ============================================================================ // Data helpers // ============================================================================ // Упрощает запись item-объектов в ecosystemSections. function createItem(number, title, type, tags = [], url = "#") { return { number, title, type, tags, url }; } // ============================================================================ // Catalog rendering // ============================================================================ // Рендерит правую часть: details-разделы, details-подразделы и карточки. // При активном поиске: // - оставляет только группы с найденными элементами; // - принудительно раскрывает найденные ветки; // - подсвечивает совпадения через highlightText(). function renderCatalog() { const query = normalize(searchInput.value); const forceOpen = Boolean(query); const fragment = document.createDocumentFragment(); let visibleItems = 0; ecosystemSections.forEach((section) => { const sectionElement = createSectionElement(section, forceOpen, query); let visibleGroups = 0; let sectionVisibleItems = 0; section.groups.forEach((group) => { const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query)); if (!filteredItems.length) { return; } visibleGroups += 1; sectionVisibleItems += filteredItems.length; sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems, forceOpen, query)); }); if (visibleGroups) { sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems; fragment.append(sectionElement); visibleItems += sectionVisibleItems; } }); catalog.replaceChildren(); if (visibleItems) { catalog.append(fragment); attachCardInteractions(); } else { catalog.append(emptyTemplate.content.cloneNode(true)); } } // Создает корневой details для раздела контента. // forceOpen нужен для поиска: найденные ветки должны быть видимы независимо // от сохраненного collapsed-состояния. function createSectionElement(section, forceOpen = false, query = "") { const sectionElement = document.createElement("details"); sectionElement.className = "catalog-section"; sectionElement.id = getSectionId(section); sectionElement.dataset.collapseKey = sectionElement.id; sectionElement.tabIndex = -1; sectionElement.open = forceOpen || getSavedOpenState(sectionElement.dataset.collapseKey); sectionElement.innerHTML = `

${highlightText(section.number, query)} раздел

${highlightText(section.title, query)}

${highlightText(section.description, query)}

0 элементов
`; return sectionElement; } // Создает details-подраздел внутри раздела. // items уже отфильтрованы на уровне renderCatalog(). function createGroupElement(group, items, forceOpen = false, query = "") { const groupElement = document.createElement("details"); groupElement.className = "catalog-group"; groupElement.id = getGroupId(group); groupElement.dataset.collapseKey = groupElement.id; groupElement.tabIndex = -1; groupElement.open = forceOpen || getSavedOpenState(groupElement.dataset.collapseKey); const cards = items.map((item) => createCardElement(item, query)); groupElement.innerHTML = `

${highlightText(group.number, query)}

${highlightText(group.title, query)}

${highlightText(group.description, query)}

${items.length}
`; const grid = document.createElement("div"); grid.className = "items-grid"; grid.append(...cards); groupElement.append(grid); return groupElement; } // Создает конечную карточку навигации. // Если url === "#", карточка считается заглушкой и не меняет текущий hash/scroll. function createCardElement(item, query = "") { const meta = serviceMeta[item.type] || serviceMeta.config; const link = document.createElement("a"); link.className = "nav-card"; link.href = item.url; link.id = getItemId(item); link.dataset.type = item.type; if (item.url === "#") { link.setAttribute("aria-label", `${item.title}: URL еще не настроен`); link.addEventListener("click", (event) => event.preventDefault()); } else { link.target = "_blank"; link.rel = "noopener noreferrer"; } link.innerHTML = `

${highlightText(item.number, query)}

${highlightText(item.title, query)}

${highlightText(meta.label, query)}

${item.tags.map((tag) => `${highlightText(tag, query)}`).join("")}
`; return link; } // ============================================================================ // Search matching and highlighting // ============================================================================ // Проверяет, подходит ли конкретный item под строку поиска. // Поиск идет не только по title, но и по номеру, type, label, tags и родителям. function matchesQuery(item, group, section, query) { if (!query) { return true; } const meta = serviceMeta[item.type] || serviceMeta.config; const haystack = normalize( [ item.number, item.title, item.type, meta.label, item.tags.join(" "), group.title, section.title, ].join(" "), ); return haystack.includes(query); } // Проверяет, есть ли совпадение на уровне группы или ее элементов. // Используется для авто-раскрытия дерева при поиске. function groupMatchesQuery(group, section, query) { if (!query) { return true; } return ( matchesText([group.number, group.title, group.description, section.title], query) || group.items.some((item) => matchesQuery(item, group, section, query)) ); } // Проверяет произвольный набор строк на наличие нормализованного query. function matchesText(values, query) { if (!query) { return true; } return normalize(values.join(" ")).includes(query); } // ============================================================================ // Sidebar tree rendering // ============================================================================ // Строит левое файловое дерево из той же структуры данных, что и каталог. // Это важно: дерево и карточки всегда остаются синхронизированными. function renderNavigationTree() { const query = normalize(searchInput.value); const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch); const fragment = document.createDocumentFragment(); ecosystemSections.forEach((section) => { const sectionOwnMatch = matchesText([section.number, section.title, section.description], query); const sectionHasMatches = sectionOwnMatch || section.groups.some((group) => groupMatchesQuery(group, section, query)); const sectionNode = document.createElement("details"); sectionNode.className = "tree-section"; sectionNode.dataset.collapseKey = getTreeSectionId(section); sectionNode.open = shouldAutoCollapse ? sectionHasMatches : getSavedOpenState(sectionNode.dataset.collapseKey); const sectionSummary = document.createElement("summary"); sectionSummary.innerHTML = ` `; sectionNode.append(sectionSummary); section.groups.forEach((group) => { const groupHasMatches = groupMatchesQuery(group, section, query); const groupNode = document.createElement("details"); groupNode.className = "tree-group"; groupNode.dataset.collapseKey = getTreeGroupId(group); groupNode.open = shouldAutoCollapse ? groupHasMatches : getSavedOpenState(groupNode.dataset.collapseKey); const groupSummary = document.createElement("summary"); groupSummary.innerHTML = ` `; groupNode.append(groupSummary); const itemList = document.createElement("div"); itemList.className = "tree-items"; group.items.forEach((item) => { const itemButton = document.createElement("button"); itemButton.type = "button"; itemButton.className = "tree-node tree-file"; itemButton.dataset.focusTarget = getItemId(item); itemButton.innerHTML = `${highlightText(item.title, query)}${highlightText(item.tags.join(", "), query)}`; itemList.append(itemButton); }); groupNode.append(itemList); sectionNode.append(groupNode); }); fragment.append(sectionNode); }); navTree.replaceChildren(fragment); } // ============================================================================ // Focusing from tree to content // ============================================================================ // Переход из дерева к правому контенту: // - если текущий поиск скрывает цель, поиск очищается; // - родительские details раскрываются; // - состояние раскрытия сохраняется; // - целевой элемент скроллится в центр и подсвечивается. function focusCatalogTarget(targetId) { let target = document.getElementById(targetId); if (!target) { searchInput.value = ""; renderCatalog(); target = document.getElementById(targetId); } if (!target) { return; } target.closest(".catalog-section")?.setAttribute("open", ""); target.closest(".catalog-group")?.setAttribute("open", ""); persistOpenedAncestor(target.closest(".catalog-section")); persistOpenedAncestor(target.closest(".catalog-group")); target.scrollIntoView({ behavior: "smooth", block: "center" }); target.focus({ preventScroll: true }); target.classList.add("is-focused"); window.setTimeout(() => target.classList.remove("is-focused"), 1200); } // ============================================================================ // Collapse state handling // ============================================================================ // Сохраняет состояние каждого details, у которого есть data-collapse-key. function handleDetailsToggle(event) { const details = event.target; if (!(details instanceof HTMLDetailsElement) || !details.dataset.collapseKey) { return; } collapseState[details.dataset.collapseKey] = details.open; saveCollapseState(); } // Принудительно сохраняет родительский details как раскрытый. // Используется, когда пользователь прыгает к элементу из дерева. function persistOpenedAncestor(details) { if (!details?.dataset.collapseKey) { return; } collapseState[details.dataset.collapseKey] = true; saveCollapseState(); } // ============================================================================ // Card pointer interaction // ============================================================================ // Добавляет мягкий tilt/highlight эффект для карточек. // После каждого renderCatalog() обработчики нужно навешивать заново, // потому что карточки пересоздаются. function attachCardInteractions() { document.querySelectorAll(".nav-card").forEach((card) => { let leaveTimer; card.addEventListener("pointerenter", () => { window.clearTimeout(leaveTimer); card.classList.add("is-hovered"); }); card.addEventListener("pointermove", (event) => { window.clearTimeout(leaveTimer); card.classList.add("is-hovered"); const rect = card.getBoundingClientRect(); const xPercent = clamp(((event.clientX - rect.left) / rect.width) * 100, 8, 92); const yPercent = clamp(((event.clientY - rect.top) / rect.height) * 100, 8, 92); const rotateY = ((xPercent - 50) / 50) * 2.4; const rotateX = ((50 - yPercent) / 50) * 2.4; card.style.setProperty("--card-x", `${xPercent}%`); card.style.setProperty("--card-y", `${yPercent}%`); card.style.setProperty("--tilt-x", `${rotateX}deg`); card.style.setProperty("--tilt-y", `${rotateY}deg`); }); card.addEventListener("pointerleave", () => { leaveTimer = window.setTimeout(() => { card.classList.remove("is-hovered"); card.style.setProperty("--card-x", "50%"); card.style.setProperty("--card-y", "50%"); card.style.setProperty("--tilt-x", "0deg"); card.style.setProperty("--tilt-y", "0deg"); }, 80); }); }); } // ============================================================================ // Preferences application // ============================================================================ // Применяет preferences к DOM: // - data-theme / data-accent для CSS; // - CSS-переменную ширины сайдбара; // - класс режима отображения каталога; // - состояния toggle-контролов в настройках. function applyPreferences(nextPreferences) { if (!availableViews.includes(nextPreferences.view)) { nextPreferences.view = defaultPreferences.view; } document.documentElement.dataset.theme = nextPreferences.theme; document.documentElement.dataset.accent = nextPreferences.accent; document.documentElement.style.setProperty( "--sidebar-width", `${clamp(Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth, 260, 560)}px`, ); catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view"); catalog.classList.add(`${nextPreferences.view}-view`); document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers)); document.documentElement.dataset.showTreeTags = String(Boolean(nextPreferences.showTreeTags)); showNumbersInput.checked = Boolean(nextPreferences.showNumbers); showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags); autoCollapseSearchInput.checked = Boolean(nextPreferences.autoCollapseSearch); setActiveButton(themeButtons, "theme", nextPreferences.theme); setActiveButton(viewButtons, "view", nextPreferences.view); setActiveButton(accentButtons, "accent", nextPreferences.accent); } // Обновляет визуальное состояние группы кнопок. function setActiveButton(buttons, key, value) { buttons.forEach((button) => { const isActive = button.dataset[key] === value; button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", String(isActive)); }); } // ============================================================================ // Generic helpers // ============================================================================ // Нормализует строки для поиска: регистр + ё/е. function normalize(value) { return value.toLowerCase().replaceAll("ё", "е").trim(); } // Подсвечивает все совпадения query внутри value. // Возвращает HTML-строку, поэтому весь обычный текст пропускается через escapeHTML(). function highlightText(value, query) { const text = String(value); const normalizedQuery = normalize(query); if (!normalizedQuery) { return escapeHTML(text); } const normalizedText = normalize(text); let cursor = 0; let result = ""; let matchIndex = normalizedText.indexOf(normalizedQuery, cursor); while (matchIndex !== -1) { result += escapeHTML(text.slice(cursor, matchIndex)); result += `${escapeHTML(text.slice(matchIndex, matchIndex + normalizedQuery.length))}`; cursor = matchIndex + normalizedQuery.length; matchIndex = normalizedText.indexOf(normalizedQuery, cursor); } return result + escapeHTML(text.slice(cursor)); } // Минимальная HTML-экранизация для безопасной вставки строк в innerHTML. function escapeHTML(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // Стабильные id для секций, групп и элементов. // Они используются одновременно как DOM id и как цель tree focus. function getSectionId(section) { return `section-${section.id}`; } function getGroupId(group) { return `group-${group.id}`; } // Отдельные ключи для дерева, чтобы collapse-состояния дерева // не конфликтовали с collapse-состояниями контента. function getTreeSectionId(section) { return `tree-${getSectionId(section)}`; } function getTreeGroupId(group) { return `tree-${getGroupId(group)}`; } function getItemId(item) { return `item-${item.number.replaceAll(".", "-")}`; } // Ограничивает число диапазоном. function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } // ============================================================================ // localStorage helpers // ============================================================================ // Читает настройки интерфейса. function readPreferences() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } catch { return {}; } } // Сохраняет настройки интерфейса. function savePreferences(nextPreferences) { localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences)); } // Возвращает сохраненное состояние details. // По умолчанию все ветки открыты, чтобы первый вход был максимально понятным. function getSavedOpenState(key) { return collapseState[key] ?? true; } // Читает состояния раскрытия дерева/контента. function readCollapseState() { try { return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {}; } catch { return {}; } } // Сохраняет состояния раскрытия дерева/контента. function saveCollapseState() { localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapseState)); } // Закрывает popup-настройки и синхронизирует aria-expanded. function closeSettingsPanel() { settingsPanel.classList.remove("is-open"); settingsOverlay.classList.remove("is-open"); settingsToggle.setAttribute("aria-expanded", "false"); }