// ============================================================================
// 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 panelScript =
document.currentScript ?? document.querySelector("script[data-ecosystem-panel]");
const PANEL_ASSET_BASE = panelScript?.dataset.assetBase ?? "";
const SETTINGS_PATH = panelScript?.dataset.settingsPath ?? "./settings.json";
function resolveAssetPath(path) {
if (!path || /^(?:[a-z]+:|\/\/|\/)/i.test(path)) {
return path;
}
return `${PANEL_ASSET_BASE}${path}`;
}
let ecosystemSections = [];
let panelTitle = "Панель экосистемы";
const defaultServiceMeta = {
analytics: { label: "Аналитика", icon: "assets/analytics.svg" },
api: { label: "API", icon: "assets/api.svg" },
calendar: { label: "Календарь", icon: "assets/calendar.svg" },
ceph: { label: "Ceph", icon: "assets/ceph.svg" },
chat: { label: "Беседа", icon: "assets/chat.svg" },
cicd: { label: "CI/CD", icon: "assets/cicd.svg" },
cloudbeaver: { label: "CloudBeaver", icon: "assets/cloudbeaver.svg" },
config: { label: "Config", icon: "assets/config.svg" },
drive: { label: "Диск", icon: "assets/drive.svg" },
forum: { label: "Форум", icon: "assets/forum.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/postgresql.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" },
social: { label: "Соцсеть", icon: "assets/social.svg" },
vault: { label: "Vault", icon: "assets/vault.svg" },
video: { label: "Видео", icon: "assets/video.svg" },
vpn: { label: "VPN", icon: "assets/vpn.svg" },
wiki: { label: "Wiki", icon: "assets/wiki.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,
navLayout: "sidebar",
showTree: true,
showResizeHandles: true,
showNumbers: false,
showTreeTags: true,
showTreeType: false,
treeMetaLayout: "inline",
treeColumns: 2,
autoCollapseSearch: true,
sidebarWidth: 390,
topBarHeight: 140,
shellMaxWidth: 2800,
sidebarMinWidth: 260,
sidebarMaxWidth: 800,
sidebarMaxWidthWide: 1400,
treeSidebarBaseWidth: 420,
treeSidebarColumnWidth: 120,
topBarMinHeight: 80,
topBarMaxHeight: 560,
catalogCardsColumns: 3,
catalogListColumns: 1,
catalogCompactCardsColumns: 5,
catalogCompactListColumns: 3,
searchPlaceholder: "Grafana, S3, журнал, сервер...",
};
const defaultPanelFeatures = {
allowNavLayoutChange: true,
allowShowTreeChange: true,
allowSearch: true,
allowViewChange: true,
allowCatalogColumnsChange: true,
allowShowNumbers: true,
allowTreeOptions: true,
allowSidebarResize: true,
allowLayoutLimitsChange: true,
allowThemeChange: true,
allowAccentChange: true,
};
const defaultPanelLimits = {
shellMaxWidth: { min: 1200, max: 3840 },
sidebarMinWidth: { min: 200, max: 480 },
sidebarMaxWidth: { min: 400, max: 1200 },
sidebarMaxWidthWide: { min: 560, max: 2000 },
treeSidebarBaseWidth: { min: 280, max: 720 },
treeSidebarColumnWidth: { min: 60, max: 200 },
topBarMinHeight: { min: 64, max: 240 },
topBarMaxHeight: { min: 160, max: 800 },
catalogCardsColumns: { min: 1, max: 6 },
catalogListColumns: { min: 1, max: 4 },
catalogCompactCardsColumns: { min: 2, max: 8 },
catalogCompactListColumns: { min: 1, max: 6 },
};
const layoutLimitKeys = [
"shellMaxWidth",
"sidebarMinWidth",
"sidebarMaxWidth",
"sidebarMaxWidthWide",
"treeSidebarBaseWidth",
"treeSidebarColumnWidth",
"topBarMinHeight",
"topBarMaxHeight",
];
const catalogColumnKeys = [
"catalogCardsColumns",
"catalogListColumns",
"catalogCompactCardsColumns",
"catalogCompactListColumns",
];
const panelLimitKeys = [...layoutLimitKeys, ...catalogColumnKeys];
const availableNavLayouts = ["sidebar", "top"];
const availableTreeMetaLayouts = ["inline", "stacked"];
const MIN_TREE_COLUMNS = 1;
const MAX_TREE_COLUMNS = 6;
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const availableAccents = ["violet", "cyan", "lime", "amber", "custom"];
// ============================================================================
// Runtime state and DOM references
// ============================================================================
// preferences - текущие настройки интерфейса; итоговые значения собираются в bootstrap().
let panelDefaults = { ...defaultPreferences };
let panelFeatures = { ...defaultPanelFeatures };
let panelLimits = structuredClone(defaultPanelLimits);
let preferences = { ...defaultPreferences };
// collapseState - объект вида { [collapseKey]: boolean } для details.
const collapseState = readCollapseState();
// Корневые DOM-узлы, которые нужны для рендера и событий.
const appLayout = document.querySelector(".app-layout");
const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]");
const sidebarResizer = document.querySelector("[data-sidebar-resizer]");
const topBarResizer = document.querySelector("[data-top-bar-resizer]");
const searchField = document.querySelector("[data-search-field]");
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 navLayoutButtons = document.querySelectorAll("[data-nav-layout]");
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 showTreeInput = document.querySelector("[data-show-tree]");
const showResizeHandlesInput = document.querySelector("[data-show-resize-handles]");
const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const showTreeTypeInput = document.querySelector("[data-show-tree-type]");
const treeColumnsInput = document.querySelector("[data-tree-columns]");
const treeColumnsValue = document.querySelector("[data-tree-columns-value]");
const treeMetaLayoutButtons = document.querySelectorAll("[data-tree-meta-layout]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const layoutLimitInputs = document.querySelectorAll("[data-layout-limit]");
const catalogColumnInputs = document.querySelectorAll("[data-catalog-columns]");
const settingsSections = document.querySelectorAll("[data-settings-section]");
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 settings = await fetchJson(SETTINGS_PATH);
applyOrganizationConfig(settings);
preferences = enforceFeatureLocks(normalizePreferences({ ...panelDefaults, ...readPreferences() }));
const panelData = await loadPanelDataFromSettings(settings);
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", () => {
if (!panelFeatures.allowThemeChange) {
return;
}
preferences.theme = button.dataset.theme;
applyPreferences(preferences);
savePreferences(preferences);
});
});
navLayoutButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowNavLayoutChange) {
return;
}
preferences.navLayout = button.dataset.navLayout;
applyPreferences(preferences);
if (isTreeVisible(preferences)) {
renderNavigationTree();
} else {
navTree.replaceChildren();
}
savePreferences(preferences);
});
});
function setShowTree(nextValue, { persist = true } = {}) {
if (!panelFeatures.allowShowTreeChange) {
return;
}
preferences.showTree = Boolean(nextValue);
applyPreferences(preferences);
if (preferences.showTree) {
renderNavigationTree();
} else {
navTree.replaceChildren();
}
if (persist) {
savePreferences(preferences);
}
}
if (showTreeInput) {
showTreeInput.addEventListener("change", () => {
setShowTree(showTreeInput.checked);
});
}
if (showResizeHandlesInput) {
showResizeHandlesInput.addEventListener("change", () => {
if (!panelFeatures.allowSidebarResize) {
return;
}
preferences.showResizeHandles = showResizeHandlesInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
}
// Переключение режима отображения каталога.
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowViewChange) {
return;
}
preferences.view = button.dataset.view;
applyPreferences(preferences);
savePreferences(preferences);
});
});
// Выбор цветового акцента из пресетов или пользовательского градиента.
accentButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.accent = button.dataset.accent;
applyPreferences(preferences);
savePreferences(preferences);
});
});
// Изменение параметров своего градиента сразу переключает accent на custom.
function handleCustomAccentChange() {
if (!panelFeatures.allowAccentChange) {
return;
}
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", () => {
if (!panelFeatures.allowShowNumbers) {
return;
}
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
// Показывает/скрывает поисковые теги в файловом дереве.
showTreeTagsInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.showTreeTags = showTreeTagsInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
showTreeTypeInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.showTreeType = showTreeTypeInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
function handleTreeColumnsChange() {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.treeColumns = getTreeColumns({ treeColumns: treeColumnsInput.value });
if (preferences.treeColumns > 1 && preferences.sidebarWidth < getSidebarMinWidth(preferences) + preferences.treeColumns * 70) {
preferences.sidebarWidth = getSidebarMinWidth(preferences) + preferences.treeColumns * 70;
}
applyPreferences(preferences);
savePreferences(preferences);
}
if (treeColumnsInput) {
treeColumnsInput.addEventListener("input", handleTreeColumnsChange);
}
treeMetaLayoutButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.treeMetaLayout = button.dataset.treeMetaLayout;
applyPreferences(preferences);
savePreferences(preferences);
});
});
// Включает/выключает авто-раскрытие веток дерева при поиске.
autoCollapseSearchInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || !isTreeVisible(preferences)) {
return;
}
preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
layoutLimitInputs.forEach((input) => {
input.addEventListener("input", () => {
if (!panelFeatures.allowLayoutLimitsChange) {
return;
}
const key = input.dataset.layoutLimit;
preferences[key] = clampLayoutPreference(key, input.value);
preferences = normalizePreferences(preferences);
applyPreferences(preferences);
savePreferences(preferences);
});
});
catalogColumnInputs.forEach((input) => {
input.addEventListener("input", () => {
if (!panelFeatures.allowCatalogColumnsChange) {
return;
}
const key = input.dataset.catalogColumns;
preferences[key] = clampCatalogColumn(key, input.value);
preferences = normalizePreferences(preferences);
applyPreferences(preferences);
savePreferences(preferences);
});
});
// Drag-resize бокового меню.
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
sidebarResizer.addEventListener("pointerdown", (event) => {
if (preferences.navLayout === "top" || !isResizeVisible(preferences)) {
return;
}
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, getSidebarMinWidth(preferences), 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);
});
// Drag-resize верхней панели в режиме navLayout: top.
topBarResizer?.addEventListener("pointerdown", (event) => {
if (preferences.navLayout !== "top" || !isResizeVisible(preferences)) {
return;
}
event.preventDefault();
document.body.classList.add("is-resizing-top-bar");
topBarResizer.setPointerCapture(event.pointerId);
const panelTop = document.querySelector(".side-panel")?.getBoundingClientRect().top ?? 0;
const handlePointerMove = (moveEvent) => {
preferences.topBarHeight = Math.round(
clamp(moveEvent.clientY - panelTop, getTopBarMinHeight(preferences), getTopBarMaxHeight(preferences)),
);
applyPreferences(preferences);
};
const handlePointerUp = () => {
document.body.classList.remove("is-resizing-top-bar");
savePreferences(preferences);
topBarResizer.removeEventListener("pointermove", handlePointerMove);
topBarResizer.removeEventListener("pointerup", handlePointerUp);
topBarResizer.removeEventListener("pointercancel", handlePointerUp);
};
topBarResizer.addEventListener("pointermove", handlePointerMove);
topBarResizer.addEventListener("pointerup", handlePointerUp);
topBarResizer.addEventListener("pointercancel", handlePointerUp);
});
// ============================================================================
// Panel config loading
// ============================================================================
async function loadPanelDataFromSettings(settings) {
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);
}
function applyOrganizationConfig(settings) {
panelLimits = normalizePanelLimits(settings?.limits);
panelDefaults = normalizePreferences({
...defaultPreferences,
...(settings?.defaults && typeof settings.defaults === "object" ? settings.defaults : {}),
});
panelFeatures = {
...defaultPanelFeatures,
...(settings?.features && typeof settings.features === "object" ? settings.features : {}),
};
const placeholder =
typeof settings?.defaults?.searchPlaceholder === "string"
? settings.defaults.searchPlaceholder.trim()
: panelDefaults.searchPlaceholder;
if (searchInput && placeholder) {
searchInput.placeholder = placeholder;
}
}
function normalizePreferences(raw) {
const next = { ...raw };
if (!availableViews.includes(next.view)) {
next.view = defaultPreferences.view;
}
if (!availableAccents.includes(next.accent)) {
next.accent = defaultPreferences.accent;
}
if (!availableNavLayouts.includes(next.navLayout)) {
next.navLayout = defaultPreferences.navLayout;
}
next.customAccentFrom =
normalizeHexColor(next.customAccentFrom) || defaultPreferences.customAccentFrom;
next.customAccentTo = normalizeHexColor(next.customAccentTo) || defaultPreferences.customAccentTo;
next.customAccentAngle = clamp(
Number(next.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
next.showNumbers = Boolean(next.showNumbers);
next.showTree = Boolean(next.showTree);
next.showResizeHandles = next.showResizeHandles !== false;
next.showTreeTags = Boolean(next.showTreeTags);
next.showTreeType = Boolean(next.showTreeType);
next.treeColumns = getTreeColumns(next);
next.treeMetaLayout = availableTreeMetaLayouts.includes(next.treeMetaLayout)
? next.treeMetaLayout
: defaultPreferences.treeMetaLayout;
next.autoCollapseSearch = Boolean(next.autoCollapseSearch);
normalizeLayoutPreferences(next);
normalizeCatalogColumns(next);
next.sidebarWidth = clamp(
Number(next.sidebarWidth) || defaultPreferences.sidebarWidth,
getSidebarMinWidth(next),
getSidebarMaxWidth(next),
);
next.topBarHeight = clamp(
Number(next.topBarHeight) || defaultPreferences.topBarHeight,
getTopBarMinHeight(next),
getTopBarMaxHeight(next),
);
return next;
}
function normalizeLayoutPreferences(next) {
for (const key of layoutLimitKeys) {
next[key] = clampLayoutPreference(key, next[key]);
}
if (next.sidebarMinWidth > next.sidebarMaxWidth) {
next.sidebarMinWidth = next.sidebarMaxWidth;
}
if (next.sidebarMaxWidth > next.sidebarMaxWidthWide) {
next.sidebarMaxWidth = next.sidebarMaxWidthWide;
}
if (next.topBarMinHeight > next.topBarMaxHeight) {
next.topBarMinHeight = next.topBarMaxHeight;
}
const minShellWidth = getSidebarMaxWidth(next) + 320;
if (next.shellMaxWidth < minShellWidth) {
next.shellMaxWidth = clampLayoutPreference("shellMaxWidth", minShellWidth);
}
}
function normalizeCatalogColumns(next) {
for (const key of catalogColumnKeys) {
next[key] = clampCatalogColumn(key, next[key]);
}
}
function enforceFeatureLocks(rawPreferences) {
const next = normalizePreferences(rawPreferences);
if (!panelFeatures.allowNavLayoutChange) {
next.navLayout = panelDefaults.navLayout;
}
if (!panelFeatures.allowShowTreeChange) {
next.showTree = panelDefaults.showTree;
}
if (!panelFeatures.allowThemeChange) {
next.theme = panelDefaults.theme;
}
if (!panelFeatures.allowViewChange) {
next.view = panelDefaults.view;
}
if (!panelFeatures.allowCatalogColumnsChange) {
for (const key of catalogColumnKeys) {
next[key] = panelDefaults[key];
}
}
if (!panelFeatures.allowAccentChange) {
next.accent = panelDefaults.accent;
next.customAccentFrom = panelDefaults.customAccentFrom;
next.customAccentTo = panelDefaults.customAccentTo;
next.customAccentAngle = panelDefaults.customAccentAngle;
}
if (!panelFeatures.allowShowNumbers) {
next.showNumbers = panelDefaults.showNumbers;
}
if (!panelFeatures.allowTreeOptions || !next.showTree) {
next.showTreeTags = panelDefaults.showTreeTags;
next.showTreeType = panelDefaults.showTreeType;
next.treeColumns = panelDefaults.treeColumns;
next.treeMetaLayout = panelDefaults.treeMetaLayout;
next.autoCollapseSearch = panelDefaults.autoCollapseSearch;
}
if (!panelFeatures.allowSidebarResize) {
next.showResizeHandles = panelDefaults.showResizeHandles;
}
if (!panelFeatures.allowSidebarResize || next.navLayout === "top") {
next.sidebarWidth = panelDefaults.sidebarWidth;
}
if (!panelFeatures.allowSidebarResize || next.navLayout !== "top") {
next.topBarHeight = panelDefaults.topBarHeight;
}
if (!panelFeatures.allowLayoutLimitsChange) {
for (const key of layoutLimitKeys) {
next[key] = panelDefaults[key];
}
normalizeLayoutPreferences(next);
}
return next;
}
async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
const settings = await fetchJson(settingsPath);
return loadPanelDataFromSettings(settings);
}
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 rootItems = Array.isArray(raw.items)
? raw.items.map((item, index) => normalizeItemFromConfig(item, index))
: [];
let sections = Array.isArray(raw.sections) ? raw.sections.map(normalizeSection) : [];
// Корневой items[] без sections — плоский список карточек.
if (!sections.length && rootItems.length) {
sections = [
{
id: "root",
number: "",
title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "Панель экосистемы",
description: String(raw.description ?? ""),
groups: [],
items: rootItems,
flat: true,
},
];
}
if (!sections.length) {
throw new Error("В данных панели нет sections или items");
}
const panelTitle =
typeof raw.title === "string" && raw.title.trim()
? raw.title.trim()
: sections[0]?.flat
? sections[0].title
: "Панель экосистемы";
return {
title: panelTitle,
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");
}
const groups = Array.isArray(section.groups) ? section.groups.map(normalizeGroup) : [];
const items = Array.isArray(section.items)
? section.items.map((item, index) => normalizeItemFromConfig(item, index))
: [];
if (!groups.length && !items.length) {
throw new Error(`Раздел "${section.title}" должен содержать groups или items`);
}
return {
id: String(section.id),
number: String(section.number ?? ""),
title: String(section.title),
description: String(section.description ?? ""),
groups,
items,
flat: Boolean(section.flat),
};
}
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, index = 0) {
if (!item?.title || !item?.type) {
throw new Error("Каждый item должен содержать title и type");
}
const itemKey = item.id
? String(item.id)
: item.number
? String(item.number)
: `auto-${index}-${slugifyItemKey(item.title)}`;
return {
itemKey,
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() : "#",
icon: typeof item.icon === "string" && item.icon.trim() ? item.icon.trim() : "",
};
}
function getItemMeta(item) {
const typeMeta = serviceMeta[item.type] || serviceMeta.config;
if (item.icon) {
return {
label: typeMeta.label,
icon: item.icon,
};
}
return typeMeta;
}
function slugifyItemKey(value) {
return normalize(String(value))
.replace(/[^a-z0-9\u0430-\u044f\u0451]+/g, "-")
.replace(/^-+|-+$/g, "") || "item";
}
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 normalizeItemFromConfig({ number, title, type, tags, url }, 0);
}
// ============================================================================
// 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) => {
if (section.flat && !section.groups.length) {
const filteredItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (!filteredItems.length) {
return;
}
fragment.append(createItemsGridElement(filteredItems, query, "items-grid catalog-flat-grid"));
visibleItems += filteredItems.length;
return;
}
const sectionElement = createSectionElement(section, forceOpen, query);
let sectionVisibleItems = 0;
const sectionContainer = sectionElement.querySelector(".section-groups");
const filteredDirectItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (filteredDirectItems.length) {
sectionVisibleItems += filteredDirectItems.length;
sectionContainer.append(createItemsGridElement(filteredDirectItems, query));
}
section.groups.forEach((group) => {
const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query));
if (!filteredItems.length) {
return;
}
sectionVisibleItems += filteredItems.length;
sectionContainer.append(createGroupElement(group, filteredItems, forceOpen, query));
});
if (sectionVisibleItems) {
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));
}
}
function createItemsGridElement(items, query = "", className = "items-grid") {
const grid = document.createElement("div");
grid.className = className;
grid.append(...items.map((item) => createCardElement(item, query)));
return grid;
}
// Создает корневой details для раздела контента.
// forceOpen нужен для поиска: найденные ветки должны быть видимы независимо
// от сохраненного collapsed-состояния.
function createSectionElement(section, forceOpen = false, query = "") {
const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section";
sectionElement.id = getSectionId(section);
sectionElement.dataset.collapseKey = sectionElement.id;
sectionElement.tabIndex = -1;
sectionElement.open = forceOpen || getSavedOpenState(sectionElement.dataset.collapseKey);
sectionElement.innerHTML = `
${highlightText(section.number, query)} раздел ${highlightText(section.description, query)}${highlightText(section.title, query)}
${highlightText(group.number, query)}
${highlightText(group.description, query)}
${highlightText(item.number, query)}
${highlightText(meta.label, query)}