universal-dashboard/script.js

1026 lines
40 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";
// ============================================================================
// Panel configuration (settings.json)
// ============================================================================
// settings.json задает источник данных панели:
// - dataUrl: URL или путь к JSON (fetch), например "./panel-data.json"
// - data: inline-объект с той же структурой, если dataUrl пустой
const SETTINGS_PATH = "./settings.json";
let ecosystemSections = [];
let panelTitle = "Панель экосистемы";
const defaultServiceMeta = {
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" },
};
let serviceMeta = { ...defaultServiceMeta };
// ============================================================================
// Default UI preferences
// ============================================================================
// Значения используются при первом открытии или если localStorage пуст/битый.
const defaultPreferences = {
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
view: "cards",
accent: "violet",
customAccentFrom: "#8b5cf6",
customAccentTo: "#22d3ee",
customAccentAngle: 135,
showNumbers: false,
showTreeTags: true,
treeTwoColumn: false,
autoCollapseSearch: true,
sidebarWidth: 390,
};
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const availableAccents = ["violet", "cyan", "lime", "amber", "custom"];
// ============================================================================
// 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 accentCustomPanel = document.querySelector("[data-accent-custom-panel]");
const customAccentFromInput = document.querySelector("[data-custom-accent-from]");
const customAccentToInput = document.querySelector("[data-custom-accent-to]");
const customAccentAngleInput = document.querySelector("[data-custom-accent-angle]");
const customAccentAngleValue = document.querySelector("[data-custom-accent-angle-value]");
const customAccentPreview = document.querySelector("[data-custom-accent-preview]");
const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const treeTwoColumnInput = document.querySelector("[data-tree-two-column]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const emptyTemplate = document.querySelector("#empty-template");
const configErrorTemplate = document.querySelector("#config-error-template");
const pageTitleElement = document.querySelector("#page-title");
// ============================================================================
// Initial render
// ============================================================================
bootstrap();
async function bootstrap() {
try {
const panelData = await loadPanelConfig();
ecosystemSections = panelData.sections;
serviceMeta = panelData.serviceMeta;
panelTitle = panelData.title;
pageTitleElement.textContent = panelTitle;
document.title = panelTitle;
} catch (error) {
showConfigError(error);
return;
}
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);
});
});
// Изменение параметров своего градиента сразу переключает accent на custom.
function handleCustomAccentChange() {
preferences.customAccentFrom = customAccentFromInput.value;
preferences.customAccentTo = customAccentToInput.value;
preferences.customAccentAngle = Number(customAccentAngleInput.value);
preferences.accent = "custom";
applyPreferences(preferences);
savePreferences(preferences);
}
customAccentFromInput.addEventListener("input", handleCustomAccentChange);
customAccentToInput.addEventListener("input", handleCustomAccentChange);
customAccentAngleInput.addEventListener("input", handleCustomAccentChange);
// Показывает/скрывает номера в секциях, группах и карточках.
showNumbersInput.addEventListener("change", () => {
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
// Показывает/скрывает поисковые теги в файловом дереве.
// Взаимоисключимо с двухколоночным режимом элементов папок.
showTreeTagsInput.addEventListener("change", () => {
preferences.showTreeTags = showTreeTagsInput.checked;
if (preferences.showTreeTags) {
preferences.treeTwoColumn = false;
}
applyPreferences(preferences);
savePreferences(preferences);
});
// Компактный двухколоночный режим элементов внутри папок дерева.
// Взаимоисключимо с отображением тегов.
treeTwoColumnInput.addEventListener("change", () => {
preferences.treeTwoColumn = treeTwoColumnInput.checked;
if (preferences.treeTwoColumn) {
preferences.showTreeTags = false;
if (preferences.sidebarWidth < 480) {
preferences.sidebarWidth = 480;
}
}
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, getSidebarMaxWidth(preferences)),
);
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);
});
// ============================================================================
// Panel config loading
// ============================================================================
async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
const settings = await fetchJson(settingsPath);
const dataUrl = typeof settings.dataUrl === "string" ? settings.dataUrl.trim() : "";
let panelSource = settings.data;
if (dataUrl) {
panelSource = await fetchJson(dataUrl);
}
if (!panelSource || typeof panelSource !== "object") {
throw new Error("settings.json должен содержать data или dataUrl");
}
return normalizePanelData(panelSource);
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Не удалось загрузить ${url}: HTTP ${response.status}`);
}
return response.json();
}
function normalizePanelData(raw) {
const sections = Array.isArray(raw.sections) ? raw.sections.map(normalizeSection) : [];
if (!sections.length) {
throw new Error("В данных панели нет sections");
}
return {
title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "Панель экосистемы",
sections,
serviceMeta: {
...defaultServiceMeta,
...(raw.serviceMeta && typeof raw.serviceMeta === "object" ? raw.serviceMeta : {}),
},
};
}
function normalizeSection(section) {
if (!section?.id || !section?.title) {
throw new Error("Каждый section должен содержать id и title");
}
return {
id: String(section.id),
number: String(section.number ?? ""),
title: String(section.title),
description: String(section.description ?? ""),
groups: Array.isArray(section.groups) ? section.groups.map(normalizeGroup) : [],
};
}
function normalizeGroup(group) {
if (!group?.id || !group?.title) {
throw new Error("Каждый group должен содержать id и title");
}
return {
id: String(group.id),
number: String(group.number ?? ""),
title: String(group.title),
description: String(group.description ?? ""),
items: Array.isArray(group.items) ? group.items.map(normalizeItemFromConfig) : [],
};
}
function normalizeItemFromConfig(item) {
if (!item?.title || !item?.type) {
throw new Error("Каждый item должен содержать title и type");
}
return {
number: String(item.number ?? ""),
title: String(item.title),
type: String(item.type),
tags: Array.isArray(item.tags) ? item.tags.map(String) : [],
url: typeof item.url === "string" && item.url.trim() ? item.url.trim() : "#",
};
}
function showConfigError(error) {
const message = error instanceof Error ? error.message : String(error);
navTree.replaceChildren();
catalog.replaceChildren();
if (configErrorTemplate) {
const node = configErrorTemplate.content.cloneNode(true);
node.querySelector("[data-config-error-message]").textContent =
`${message}. Проверь settings.json и доступность dataUrl.`;
catalog.append(node);
return;
}
catalog.textContent = message;
}
// ============================================================================
// Data helpers
// ============================================================================
// Упрощает запись item-объектов при ручной правке JS (legacy helper).
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;
}
if (!availableAccents.includes(nextPreferences.accent)) {
nextPreferences.accent = defaultPreferences.accent;
}
nextPreferences.customAccentFrom = normalizeHexColor(nextPreferences.customAccentFrom) || defaultPreferences.customAccentFrom;
nextPreferences.customAccentTo = normalizeHexColor(nextPreferences.customAccentTo) || defaultPreferences.customAccentTo;
nextPreferences.customAccentAngle = clamp(
Number(nextPreferences.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
if (nextPreferences.treeTwoColumn) {
nextPreferences.showTreeTags = false;
} else if (nextPreferences.showTreeTags) {
nextPreferences.treeTwoColumn = false;
}
document.documentElement.dataset.theme = nextPreferences.theme;
document.documentElement.dataset.accent = nextPreferences.accent;
applyAccentVariables(nextPreferences);
document.documentElement.style.setProperty(
"--sidebar-width",
`${clamp(
Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
260,
getSidebarMaxWidth(nextPreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--shell-max-width",
nextPreferences.treeTwoColumn ? "1920px" : "1560px",
);
document.documentElement.style.setProperty(
"--sidebar-max-width",
`${getSidebarMaxWidth(nextPreferences)}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));
document.documentElement.dataset.treeTwoColumn = String(Boolean(nextPreferences.treeTwoColumn));
showNumbersInput.checked = Boolean(nextPreferences.showNumbers);
showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags);
treeTwoColumnInput.checked = Boolean(nextPreferences.treeTwoColumn);
autoCollapseSearchInput.checked = Boolean(nextPreferences.autoCollapseSearch);
customAccentFromInput.value = nextPreferences.customAccentFrom;
customAccentToInput.value = nextPreferences.customAccentTo;
customAccentAngleInput.value = String(nextPreferences.customAccentAngle);
customAccentAngleValue.textContent = `${nextPreferences.customAccentAngle}°`;
accentCustomPanel.hidden = nextPreferences.accent !== "custom";
updateCustomAccentPreview(nextPreferences);
setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent);
}
// Применяет CSS-переменные акцента для пресетов или пользовательского градиента.
function applyAccentVariables(nextPreferences) {
const root = document.documentElement;
if (nextPreferences.accent === "custom") {
root.style.setProperty("--accent", nextPreferences.customAccentFrom);
root.style.setProperty("--accent-2", nextPreferences.customAccentTo);
root.style.setProperty("--accent-angle", `${nextPreferences.customAccentAngle}deg`);
const rgb = hexToRgb(nextPreferences.customAccentFrom);
if (rgb) {
root.style.setProperty("--accent-rgb", `${rgb.r}, ${rgb.g}, ${rgb.b}`);
}
return;
}
root.style.removeProperty("--accent");
root.style.removeProperty("--accent-2");
root.style.removeProperty("--accent-rgb");
root.style.removeProperty("--accent-angle");
}
// Обновляет превью пользовательского градиента в настройках и на кнопке custom.
function updateCustomAccentPreview(nextPreferences) {
const gradient = `linear-gradient(${nextPreferences.customAccentAngle}deg, ${nextPreferences.customAccentFrom}, ${nextPreferences.customAccentTo})`;
customAccentPreview.style.background = gradient;
const customSwatch = document.querySelector('[data-accent="custom"]');
if (customSwatch) {
customSwatch.style.setProperty("--custom-accent-from", nextPreferences.customAccentFrom);
customSwatch.style.setProperty("--custom-accent-to", nextPreferences.customAccentTo);
customSwatch.style.setProperty("--custom-accent-angle", `${nextPreferences.customAccentAngle}deg`);
}
}
// Приводит hex-цвет к формату #rrggbb для input[type="color"].
function normalizeHexColor(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (/^#[0-9a-f]{6}$/.test(normalized)) {
return normalized;
}
if (/^#[0-9a-f]{3}$/.test(normalized)) {
return `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`;
}
return null;
}
// Конвертирует hex в RGB для CSS-переменной --accent-rgb.
function hexToRgb(value) {
const hex = normalizeHexColor(value);
if (!hex) {
return null;
}
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
// Обновляет визуальное состояние группы кнопок.
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);
}
// Максимальная ширина sidebar зависит от режима дерева.
function getSidebarMaxWidth(nextPreferences) {
return nextPreferences.treeTwoColumn ? 760 : 560;
}
// ============================================================================
// 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");
}