976 lines
43 KiB
JavaScript
976 lines
43 KiB
JavaScript
// ============================================================================
|
||
// 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",
|
||
customAccentFrom: "#8b5cf6",
|
||
customAccentTo: "#22d3ee",
|
||
customAccentAngle: 135,
|
||
showNumbers: false,
|
||
showTreeTags: true,
|
||
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 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);
|
||
});
|
||
});
|
||
|
||
// Изменение параметров своего градиента сразу переключает 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;
|
||
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;
|
||
}
|
||
|
||
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,
|
||
);
|
||
|
||
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, 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);
|
||
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("&", "&")
|
||
.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");
|
||
}
|