universal-dashboard/script.js

863 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================================
// 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 = `
<summary class="section-head">
<div>
<p class="section-number">${highlightText(section.number, query)} раздел</p>
<h2>${highlightText(section.title, query)}</h2>
<p>${highlightText(section.description, query)}</p>
</div>
<span class="section-meta">
<span class="section-count"><span data-section-count>0</span> элементов</span>
<span class="collapse-indicator" aria-hidden="true">⌄</span>
</span>
</summary>
<div class="section-groups"></div>
`;
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 = `
<summary class="group-head">
<div>
<p class="group-number">${highlightText(group.number, query)}</p>
<h3>${highlightText(group.title, query)}</h3>
<p>${highlightText(group.description, query)}</p>
</div>
<span class="group-meta">
<span class="group-count">${items.length}</span>
<span class="collapse-indicator" aria-hidden="true">⌄</span>
</span>
</summary>
`;
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 = `
<span class="shine"></span>
<img src="${meta.icon}" alt="" loading="lazy" />
<div class="card-content">
<p class="item-number">${highlightText(item.number, query)}</p>
<h4>${highlightText(item.title, query)}</h4>
<p class="item-type">${highlightText(meta.label, query)}</p>
<div class="tags">${item.tags.map((tag) => `<span>${highlightText(tag, query)}</span>`).join("")}</div>
</div>
<span class="arrow" aria-hidden="true">→</span>
`;
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 = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getSectionId(section)}">
<span>${highlightText(section.title, query)}</span>
</button>
`;
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 = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getGroupId(group)}">
<span>${highlightText(group.title, query)}</span>
</button>
`;
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 = `<span>${highlightText(item.title, query)}</span><small>${highlightText(item.tags.join(", "), query)}</small>`;
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 += `<mark class="search-mark">${escapeHTML(text.slice(matchIndex, matchIndex + normalizedQuery.length))}</mark>`;
cursor = matchIndex + normalizedQuery.length;
matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
}
return result + escapeHTML(text.slice(cursor));
}
// Минимальная HTML-экранизация для безопасной вставки строк в innerHTML.
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
// Стабильные 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");
}