universal-dashboard/examples/shared/script.js

2210 lines
78 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 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 };
const availablePanelPatterns = ["none", "hex", "dots", "grid", "diagonal", "cross", "mesh"];
const panelPatternTargetIds = ["nav", "section", "group", "item"];
const panelPatternTuningDefaults = {
opacity: 70,
height: 45,
fade: 10,
gradientOpacity: 80,
gradientHeight: 40,
gradientFade: 8,
};
const panelPatternTargetMeta = {
nav: { defaultPattern: "hex" },
section: { defaultPattern: "hex" },
group: { defaultPattern: "none" },
item: { defaultPattern: "none" },
};
function capitalizePanelPatternTarget(target) {
return target.charAt(0).toUpperCase() + target.slice(1);
}
function buildDefaultPanelPatternPreferences() {
const prefs = {};
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
prefs[`panelPattern${cap}`] = panelPatternTargetMeta[target].defaultPattern;
prefs[`panelPattern${cap}Opacity`] = panelPatternTuningDefaults.opacity;
prefs[`panelPattern${cap}Height`] = panelPatternTuningDefaults.height;
prefs[`panelPattern${cap}Fade`] = panelPatternTuningDefaults.fade;
prefs[`panelPattern${cap}GradientOpacity`] = panelPatternTuningDefaults.gradientOpacity;
prefs[`panelPattern${cap}GradientHeight`] = panelPatternTuningDefaults.gradientHeight;
prefs[`panelPattern${cap}GradientFade`] = panelPatternTuningDefaults.gradientFade;
}
return prefs;
}
// ============================================================================
// 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,
...buildDefaultPanelPatternPreferences(),
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, журнал, сервер...",
settingsPanelWidth: 460,
};
const defaultPanelFeatures = {
allowNavLayoutChange: true,
allowShowTreeChange: true,
allowSearch: true,
allowViewChange: true,
allowCatalogColumnsChange: true,
allowShowNumbers: true,
allowTreeOptions: true,
allowSidebarResize: true,
allowLayoutLimitsChange: true,
allowThemeChange: true,
allowAccentChange: true,
allowPanelPatternChange: 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 },
settingsPanelWidth: { min: 320, max: 720 },
};
const layoutLimitKeys = [
"shellMaxWidth",
"sidebarMinWidth",
"sidebarMaxWidth",
"sidebarMaxWidthWide",
"treeSidebarBaseWidth",
"treeSidebarColumnWidth",
"topBarMinHeight",
"topBarMaxHeight",
];
const catalogColumnKeys = [
"catalogCardsColumns",
"catalogListColumns",
"catalogCompactCardsColumns",
"catalogCompactListColumns",
];
const panelLimitKeys = [...layoutLimitKeys, ...catalogColumnKeys, "settingsPanelWidth"];
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 patternTargetsPanel = document.querySelector("[data-pattern-targets-panel]");
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 settingsPanelWidthInput = document.querySelector("[data-settings-panel-width]");
const settingsPanelWidthValue = document.querySelector("[data-settings-panel-width-value]");
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);
});
});
patternTargetsPanel?.addEventListener("click", (event) => {
const button = event.target.closest("[data-panel-pattern-target][data-panel-pattern]");
if (!button || !panelFeatures.allowPanelPatternChange) {
return;
}
const target = button.dataset.panelPatternTarget;
preferences[getPanelPatternPreferenceKey(target, "pattern")] = button.dataset.panelPattern;
applyPreferences(preferences);
savePreferences(preferences);
});
patternTargetsPanel?.addEventListener("input", (event) => {
const input = event.target.closest("[data-panel-pattern-tuning]");
if (!input || !panelFeatures.allowPanelPatternChange) {
return;
}
const target = input.dataset.panelPatternTuning;
const field = input.dataset.panelPatternField;
const key = getPanelPatternPreferenceKey(target, field);
const fallback = defaultPreferences[key] ?? panelPatternTuningDefaults[field] ?? 50;
preferences[key] = clampPanelPatternPercent(input.value, fallback);
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);
}
function handleSettingsPanelWidthChange() {
preferences.settingsPanelWidth = clampLayoutPreference(
"settingsPanelWidth",
settingsPanelWidthInput?.value,
);
applyPreferences(preferences);
savePreferences(preferences);
}
settingsPanelWidthInput?.addEventListener("input", handleSettingsPanelWidthChange);
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;
}
migrateLegacyPanelPatternPreferences(next);
normalizePanelPatternPreferences(next);
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),
);
next.settingsPanelWidth = clampLayoutPreference("settingsPanelWidth", next.settingsPanelWidth);
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.allowPanelPatternChange) {
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
next[`panelPattern${cap}`] = panelDefaults[`panelPattern${cap}`];
next[`panelPattern${cap}Opacity`] = panelDefaults[`panelPattern${cap}Opacity`];
next[`panelPattern${cap}Height`] = panelDefaults[`panelPattern${cap}Height`];
next[`panelPattern${cap}Fade`] = panelDefaults[`panelPattern${cap}Fade`];
next[`panelPattern${cap}GradientOpacity`] = panelDefaults[`panelPattern${cap}GradientOpacity`];
next[`panelPattern${cap}GradientHeight`] = panelDefaults[`panelPattern${cap}GradientHeight`];
next[`panelPattern${cap}GradientFade`] = panelDefaults[`panelPattern${cap}GradientFade`];
}
}
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 = `
<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 = getItemMeta(item);
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="${resolveAssetPath(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 ?? "",
group?.description ?? "",
section.title,
].join(" "),
);
return haystack.includes(query);
}
function sectionMatchesQuery(section, query) {
if (!query) {
return true;
}
return (
matchesText([section.number, section.title, section.description], query) ||
section.items.some((item) => matchesQuery(item, null, section, query)) ||
section.groups.some((group) => groupMatchesQuery(group, section, 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() {
if (!isTreeVisible(preferences)) {
navTree.replaceChildren();
return;
}
const query = normalize(searchInput.value);
const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch);
const fragment = document.createDocumentFragment();
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(createTreeItemList(filteredItems, query));
return;
}
const sectionHasMatches = sectionMatchesQuery(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);
const filteredDirectItems = section.items.filter((item) => matchesQuery(item, null, section, query));
if (filteredDirectItems.length) {
sectionNode.append(createTreeItemList(filteredDirectItems, query, "tree-items tree-items-direct"));
}
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);
groupNode.append(createTreeItemList(group.items.filter((item) => matchesQuery(item, group, section, query)), query));
sectionNode.append(groupNode);
});
fragment.append(sectionNode);
});
navTree.replaceChildren(fragment);
}
function createTreeItemList(items, query = "", className = "tree-items") {
const itemList = document.createElement("div");
itemList.className = className;
items.forEach((item) => {
itemList.append(createTreeItemButton(item, query));
});
return itemList;
}
function createTreeItemButton(item, query = "") {
const meta = getItemMeta(item);
const itemButton = document.createElement("button");
itemButton.type = "button";
itemButton.className = "tree-node tree-file";
itemButton.dataset.focusTarget = getItemId(item);
itemButton.innerHTML = `
<img class="tree-node-icon" src="${resolveAssetPath(meta.icon)}" alt="" loading="lazy" />
<div class="tree-file-body">
<span class="tree-file-title">${highlightText(item.title, query)}</span>
<div class="tree-file-meta">
<span class="tree-file-type">${highlightText(meta.label, query)}</span>
<div class="tree-tags tags">${renderTreeTagSpans(item.tags, query)}</div>
</div>
</div>
`;
return itemButton;
}
function renderTreeTagSpans(tags, query = "") {
if (!Array.isArray(tags) || !tags.length) {
return "";
}
return tags.map((tag) => `<span>${highlightText(tag, query)}</span>`).join("");
}
// ============================================================================
// 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) {
const lockedPreferences = enforceFeatureLocks(nextPreferences);
Object.assign(preferences, lockedPreferences);
const activePreferences = preferences;
document.documentElement.dataset.theme = activePreferences.theme;
document.documentElement.dataset.accent = activePreferences.accent;
applyPanelPatternPreferences(activePreferences);
document.documentElement.dataset.navLayout =
activePreferences.navLayout === "top" ? "top" : "sidebar";
document.documentElement.dataset.showTree = String(isTreeVisible(activePreferences));
document.documentElement.dataset.showResizeHandles = String(isResizeVisible(activePreferences));
applyAccentVariables(activePreferences);
document.documentElement.style.setProperty(
"--sidebar-width",
`${clamp(
Number(activePreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
getSidebarMinWidth(activePreferences),
getSidebarMaxWidth(activePreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--top-bar-height",
`${clamp(
Number(activePreferences.topBarHeight) || defaultPreferences.topBarHeight,
getTopBarMinHeight(activePreferences),
getTopBarMaxHeight(activePreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--settings-panel-width",
`${clampLayoutPreference("settingsPanelWidth", activePreferences.settingsPanelWidth)}px`,
);
const treeColumns = getTreeColumns(activePreferences);
const sidebarMaxWidth = getSidebarMaxWidth(activePreferences);
document.documentElement.style.setProperty(
"--shell-max-width",
`${getShellMaxWidth(activePreferences)}px`,
);
document.documentElement.style.setProperty("--sidebar-max-width", `${sidebarMaxWidth}px`);
document.documentElement.style.setProperty(
"--tree-sidebar-base-width",
`${activePreferences.treeSidebarBaseWidth}px`,
);
document.documentElement.style.setProperty(
"--tree-sidebar-column-width",
`${activePreferences.treeSidebarColumnWidth}px`,
);
document.documentElement.style.setProperty(
"--catalog-cards-columns",
String(activePreferences.catalogCardsColumns),
);
document.documentElement.style.setProperty(
"--catalog-list-columns",
String(activePreferences.catalogListColumns),
);
document.documentElement.style.setProperty(
"--catalog-compact-cards-columns",
String(activePreferences.catalogCompactCardsColumns),
);
document.documentElement.style.setProperty(
"--catalog-compact-list-columns",
String(activePreferences.catalogCompactListColumns),
);
document.documentElement.style.setProperty("--tree-columns", String(treeColumns));
catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
catalog.classList.add(`${activePreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(activePreferences.showNumbers));
document.documentElement.dataset.showTreeTags = String(Boolean(activePreferences.showTreeTags));
document.documentElement.dataset.showTreeType = String(Boolean(activePreferences.showTreeType));
document.documentElement.dataset.treeMetaLayout = activePreferences.treeMetaLayout;
document.documentElement.dataset.treeColumns = String(treeColumns);
if (showTreeInput) {
showTreeInput.checked = Boolean(activePreferences.showTree);
}
if (showResizeHandlesInput) {
showResizeHandlesInput.checked = Boolean(activePreferences.showResizeHandles);
}
showNumbersInput.checked = Boolean(activePreferences.showNumbers);
showTreeTagsInput.checked = Boolean(activePreferences.showTreeTags);
if (showTreeTypeInput) {
showTreeTypeInput.checked = Boolean(activePreferences.showTreeType);
}
if (treeColumnsInput) {
treeColumnsInput.value = String(treeColumns);
}
if (treeColumnsValue) {
treeColumnsValue.textContent = String(treeColumns);
}
autoCollapseSearchInput.checked = Boolean(activePreferences.autoCollapseSearch);
customAccentFromInput.value = activePreferences.customAccentFrom;
customAccentToInput.value = activePreferences.customAccentTo;
customAccentAngleInput.value = String(activePreferences.customAccentAngle);
customAccentAngleValue.textContent = `${activePreferences.customAccentAngle}°`;
accentCustomPanel.hidden = activePreferences.accent !== "custom";
updateCustomAccentPreview(activePreferences);
setActiveButton(themeButtons, "theme", activePreferences.theme);
setActiveButton(navLayoutButtons, "navLayout", activePreferences.navLayout);
setActiveButton(viewButtons, "view", activePreferences.view);
setActiveButton(accentButtons, "accent", activePreferences.accent);
syncPatternTargetPanels(activePreferences);
setActiveButton(treeMetaLayoutButtons, "treeMetaLayout", activePreferences.treeMetaLayout);
syncLayoutLimitInputs(activePreferences);
syncCatalogColumnInputs(activePreferences);
syncSettingsPanelWidthInput(activePreferences);
applyPanelFeatures(activePreferences);
}
function applyPanelFeatures(activePreferences) {
settingsSections.forEach((section) => {
const featureName = section.dataset.settingsSection;
section.hidden = !isSettingsSectionVisible(featureName, activePreferences);
});
if (searchField) {
searchField.hidden = !panelFeatures.allowSearch;
}
if (navTree) {
navTree.hidden = !isTreeVisible(activePreferences);
}
if (sidebarResizer) {
sidebarResizer.hidden =
activePreferences.navLayout === "top" || !isResizeVisible(activePreferences);
}
if (topBarResizer) {
topBarResizer.hidden =
activePreferences.navLayout !== "top" || !isResizeVisible(activePreferences);
}
if (appLayout) {
appLayout.dataset.navLayout = activePreferences.navLayout === "top" ? "top" : "sidebar";
}
document.querySelectorAll("[data-feature]").forEach((element) => {
const featureName = element.dataset.feature;
element.hidden = featureName ? !panelFeatures[featureName] : false;
});
document.querySelectorAll("[data-show-when-sidebar]").forEach((element) => {
element.hidden = activePreferences.navLayout === "top";
});
}
function isSettingsSectionVisible(sectionName, activePreferences) {
switch (sectionName) {
case "layout":
return (
panelFeatures.allowNavLayoutChange ||
panelFeatures.allowShowTreeChange ||
panelFeatures.allowSidebarResize
);
case "dimensions":
return panelFeatures.allowLayoutLimitsChange;
case "catalog":
return (
panelFeatures.allowViewChange ||
panelFeatures.allowShowNumbers ||
panelFeatures.allowCatalogColumnsChange
);
case "tree":
return panelFeatures.allowTreeOptions && activePreferences.showTree;
case "appearance":
return (
panelFeatures.allowThemeChange ||
panelFeatures.allowAccentChange ||
panelFeatures.allowPanelPatternChange
);
default:
return true;
}
}
function isTreeVisible(activePreferences) {
return Boolean(activePreferences.showTree);
}
function isResizeVisible(activePreferences) {
return panelFeatures.allowSidebarResize && Boolean(activePreferences.showResizeHandles);
}
// Применяет 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-${String(item.itemKey).replaceAll(".", "-")}`;
}
// Ограничивает число диапазоном.
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getTreeColumns(nextPreferences) {
let columns = Number(nextPreferences.treeColumns);
if (!Number.isFinite(columns)) {
columns = nextPreferences.treeTwoColumn ? 2 : defaultPreferences.treeColumns;
}
return clamp(Math.round(columns), MIN_TREE_COLUMNS, MAX_TREE_COLUMNS);
}
function getLayoutLimitRange(key) {
const limits = panelLimits[key] ?? defaultPanelLimits[key];
if (!limits || !Number.isFinite(limits.min) || !Number.isFinite(limits.max)) {
return defaultPanelLimits[key];
}
return {
min: Math.min(limits.min, limits.max),
max: Math.max(limits.min, limits.max),
};
}
function clampLayoutPreference(key, value) {
const limits = getLayoutLimitRange(key);
const fallback = panelDefaults[key] ?? defaultPreferences[key];
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return clamp(Math.round(numeric), limits.min, limits.max);
}
function normalizePanelLimits(rawLimits) {
const next = structuredClone(defaultPanelLimits);
if (!rawLimits || typeof rawLimits !== "object") {
return next;
}
for (const key of panelLimitKeys) {
const source = rawLimits[key];
if (!source || typeof source !== "object") {
continue;
}
const min = Math.round(Number(source.min));
const max = Math.round(Number(source.max));
if (!Number.isFinite(min) || !Number.isFinite(max)) {
continue;
}
next[key] = {
min: Math.min(min, max),
max: Math.max(min, max),
};
}
return next;
}
function syncLayoutLimitInputs(activePreferences) {
layoutLimitInputs.forEach((input) => {
const key = input.dataset.layoutLimit;
if (!layoutLimitKeys.includes(key)) {
return;
}
const limits = getPanelLimitRange(key);
input.min = String(limits.min);
input.max = String(limits.max);
input.step = key === "treeSidebarColumnWidth" ? "5" : "10";
input.value = String(activePreferences[key]);
input.disabled = !panelFeatures.allowLayoutLimitsChange;
const output = document.querySelector(`[data-layout-limit-value="${key}"]`);
if (output) {
output.textContent = String(activePreferences[key]);
}
});
}
function syncSettingsPanelWidthInput(activePreferences) {
if (!settingsPanelWidthInput) {
return;
}
const limits = getPanelLimitRange("settingsPanelWidth");
settingsPanelWidthInput.min = String(limits.min);
settingsPanelWidthInput.max = String(limits.max);
settingsPanelWidthInput.step = "10";
settingsPanelWidthInput.value = String(activePreferences.settingsPanelWidth);
if (settingsPanelWidthValue) {
settingsPanelWidthValue.textContent = `${activePreferences.settingsPanelWidth}px`;
}
}
function syncCatalogColumnInputs(activePreferences) {
catalogColumnInputs.forEach((input) => {
const key = input.dataset.catalogColumns;
if (!catalogColumnKeys.includes(key)) {
return;
}
const limits = getPanelLimitRange(key);
input.min = String(limits.min);
input.max = String(limits.max);
input.step = "1";
input.value = String(activePreferences[key]);
input.disabled = !panelFeatures.allowCatalogColumnsChange;
const output = document.querySelector(`[data-catalog-columns-value="${key}"]`);
if (output) {
output.textContent = String(activePreferences[key]);
}
});
}
function getPanelLimitRange(key) {
return getLayoutLimitRange(key);
}
function clampPanelPatternPercent(value, fallback = 50) {
const numeric = Number(value);
const base = Number.isFinite(numeric) ? numeric : fallback;
return clamp(Math.round(base), 0, 100);
}
function getPanelPatternPreferenceKey(target, field) {
const cap = capitalizePanelPatternTarget(target);
switch (field) {
case "pattern":
return `panelPattern${cap}`;
case "opacity":
return `panelPattern${cap}Opacity`;
case "height":
return `panelPattern${cap}Height`;
case "fade":
return `panelPattern${cap}Fade`;
case "gradientOpacity":
return `panelPattern${cap}GradientOpacity`;
case "gradientHeight":
return `panelPattern${cap}GradientHeight`;
case "gradientFade":
return `panelPattern${cap}GradientFade`;
default:
return `panelPattern${cap}`;
}
}
function migrateLegacyPanelPatternPreferences(next) {
if (next.panelPatternNav !== undefined) {
return;
}
const legacyPattern = availablePanelPatterns.includes(next.panelPattern)
? next.panelPattern
: panelPatternTargetMeta.nav.defaultPattern;
const legacyOpacity = next.panelPatternOpacity ?? panelPatternTuningDefaults.opacity;
const legacyHeight = next.panelPatternHeight ?? panelPatternTuningDefaults.height;
const legacyGradientOpacity = next.panelGradientOpacity ?? panelPatternTuningDefaults.gradientOpacity;
const legacyGradientHeight = next.panelGradientHeight ?? panelPatternTuningDefaults.gradientHeight;
const legacyTargets = [
["Nav", next.panelPatternOnNav !== false],
["Section", next.panelPatternOnSection !== false],
["Group", Boolean(next.panelPatternOnGroup)],
["Item", Boolean(next.panelPatternOnItem)],
];
for (const [suffix, enabled] of legacyTargets) {
next[`panelPattern${suffix}`] = enabled && legacyPattern !== "none" ? legacyPattern : "none";
next[`panelPattern${suffix}Opacity`] = legacyOpacity;
next[`panelPattern${suffix}Height`] = legacyHeight;
next[`panelPattern${suffix}Fade`] = panelPatternTuningDefaults.fade;
next[`panelPattern${suffix}GradientOpacity`] = legacyGradientOpacity;
next[`panelPattern${suffix}GradientHeight`] = legacyGradientHeight;
next[`panelPattern${suffix}GradientFade`] = panelPatternTuningDefaults.gradientFade;
}
}
function normalizePanelPatternPreferences(next) {
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
const patternKey = `panelPattern${cap}`;
if (!availablePanelPatterns.includes(next[patternKey])) {
next[patternKey] = panelPatternTargetMeta[target].defaultPattern;
}
next[`panelPattern${cap}Opacity`] = clampPanelPatternPercent(
next[`panelPattern${cap}Opacity`],
panelPatternTuningDefaults.opacity,
);
next[`panelPattern${cap}Height`] = clampPanelPatternPercent(
next[`panelPattern${cap}Height`],
panelPatternTuningDefaults.height,
);
next[`panelPattern${cap}Fade`] = clampPanelPatternPercent(
next[`panelPattern${cap}Fade`],
panelPatternTuningDefaults.fade,
);
next[`panelPattern${cap}GradientOpacity`] = clampPanelPatternPercent(
next[`panelPattern${cap}GradientOpacity`],
panelPatternTuningDefaults.gradientOpacity,
);
next[`panelPattern${cap}GradientHeight`] = clampPanelPatternPercent(
next[`panelPattern${cap}GradientHeight`],
panelPatternTuningDefaults.gradientHeight,
);
next[`panelPattern${cap}GradientFade`] = clampPanelPatternPercent(
next[`panelPattern${cap}GradientFade`],
panelPatternTuningDefaults.gradientFade,
);
}
}
function getPanelPatternFadeStart(heightPercent, fadePercent) {
const height = clampPanelPatternPercent(heightPercent, panelPatternTuningDefaults.height);
const fade = clampPanelPatternPercent(fadePercent, panelPatternTuningDefaults.fade);
return `${Math.max(0, height - fade)}%`;
}
function applyPanelPatternPreferences(activePreferences) {
for (const target of panelPatternTargetIds) {
const cap = capitalizePanelPatternTarget(target);
const pattern = activePreferences[`panelPattern${cap}`] || "none";
const height = clampPanelPatternPercent(
activePreferences[`panelPattern${cap}Height`],
panelPatternTuningDefaults.height,
);
const opacityPercent = clampPanelPatternPercent(
activePreferences[`panelPattern${cap}Opacity`],
panelPatternTuningDefaults.opacity,
);
const patternOpacity = pattern === "none" || height === 0 ? 0 : opacityPercent / 100;
document.documentElement.dataset[`panelPattern${cap}`] = pattern;
document.documentElement.style.setProperty(`--panel-pattern-${target}-opacity`, String(patternOpacity));
document.documentElement.style.setProperty(`--panel-pattern-${target}-height`, `${height}%`);
document.documentElement.style.setProperty(
`--panel-pattern-${target}-fade-start`,
getPanelPatternFadeStart(height, activePreferences[`panelPattern${cap}Fade`]),
);
document.documentElement.style.setProperty(
`--panel-gradient-${target}-strength`,
String(
(clampPanelPatternPercent(
activePreferences[`panelPattern${cap}GradientOpacity`],
panelPatternTuningDefaults.gradientOpacity,
) /
100) *
0.28,
),
);
const gradientHeight = clampPanelPatternPercent(
activePreferences[`panelPattern${cap}GradientHeight`],
panelPatternTuningDefaults.gradientHeight,
);
document.documentElement.style.setProperty(
`--panel-gradient-${target}-height`,
`${gradientHeight}%`,
);
document.documentElement.style.setProperty(
`--panel-gradient-${target}-fade-start`,
getPanelPatternFadeStart(gradientHeight, activePreferences[`panelPattern${cap}GradientFade`]),
);
}
}
function syncPatternTargetPanels(activePreferences) {
if (!patternTargetsPanel) {
return;
}
const disabled = !panelFeatures.allowPanelPatternChange;
const tuningFields = ["opacity", "height", "fade", "gradientOpacity", "gradientHeight", "gradientFade"];
for (const target of panelPatternTargetIds) {
const pattern = activePreferences[getPanelPatternPreferenceKey(target, "pattern")];
patternTargetsPanel
.querySelectorAll(`[data-panel-pattern-target="${target}"][data-panel-pattern]`)
.forEach((button) => {
const isActive = button.dataset.panelPattern === pattern;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
button.disabled = disabled;
});
for (const field of tuningFields) {
const key = getPanelPatternPreferenceKey(target, field);
const input = patternTargetsPanel.querySelector(
`[data-panel-pattern-tuning="${target}"][data-panel-pattern-field="${field}"]`,
);
const output = patternTargetsPanel.querySelector(
`[data-panel-pattern-value="${target}"][data-panel-pattern-field="${field}"]`,
);
if (input) {
input.value = String(activePreferences[key]);
input.disabled = disabled;
}
if (output) {
output.textContent = `${activePreferences[key]}%`;
}
}
}
}
function clampCatalogColumn(key, value) {
const limits = getPanelLimitRange(key);
const fallback = panelDefaults[key] ?? defaultPreferences[key];
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return clamp(Math.round(numeric), limits.min, limits.max);
}
function getSidebarMinWidth(nextPreferences) {
return Number(nextPreferences.sidebarMinWidth);
}
function getSidebarMaxWidth(nextPreferences) {
const columns = getTreeColumns(nextPreferences);
if (columns <= 1) {
return Number(nextPreferences.sidebarMaxWidth);
}
return Math.min(
Number(nextPreferences.sidebarMaxWidthWide),
Number(nextPreferences.treeSidebarBaseWidth) +
columns * Number(nextPreferences.treeSidebarColumnWidth),
);
}
function getShellMaxWidth(nextPreferences) {
return Number(nextPreferences.shellMaxWidth);
}
function getTopBarMinHeight(nextPreferences) {
return Number(nextPreferences.topBarMinHeight);
}
function getTopBarMaxHeight(nextPreferences) {
return Math.min(
Number(nextPreferences.topBarMaxHeight),
Math.round(window.innerHeight * 0.75),
);
}
// ============================================================================
// 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");
}