2210 lines
78 KiB
JavaScript
2210 lines
78 KiB
JavaScript
// ============================================================================
|
||
// Persistent storage keys
|
||
// ============================================================================
|
||
// STORAGE_KEY хранит пользовательские настройки интерфейса:
|
||
// тему, акцент, режим отображения, ширину сайдбара и toggle-настройки.
|
||
const STORAGE_KEY = "ecosystem-panel-preferences";
|
||
|
||
// COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details:
|
||
// отдельно для дерева навигации и отдельно для контентных секций.
|
||
const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
|
||
|
||
// ============================================================================
|
||
// 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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
// Стабильные id для секций, групп и элементов.
|
||
// Они используются одновременно как DOM id и как цель tree focus.
|
||
function getSectionId(section) {
|
||
return `section-${section.id}`;
|
||
}
|
||
|
||
function getGroupId(group) {
|
||
return `group-${group.id}`;
|
||
}
|
||
|
||
// Отдельные ключи для дерева, чтобы collapse-состояния дерева
|
||
// не конфликтовали с collapse-состояниями контента.
|
||
function getTreeSectionId(section) {
|
||
return `tree-${getSectionId(section)}`;
|
||
}
|
||
|
||
function getTreeGroupId(group) {
|
||
return `tree-${getGroupId(group)}`;
|
||
}
|
||
|
||
function getItemId(item) {
|
||
return `item-${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");
|
||
}
|