добавление дефолтных настроек, возможность меню сбоку и сверху

This commit is contained in:
Илья Братских 2026-06-28 16:44:11 +03:00 committed by KBAHTNET
parent 284a7f6b75
commit f3508bf7f9
17 changed files with 1468 additions and 239 deletions

View File

@ -11,6 +11,7 @@
- [Структура проекта](#структура-проекта)
- [Конфигурация](#конфигурация)
- [`settings.json`](#settingsjson)
- [Пресет организации (`defaults` + `features`)](#пресет-организации-defaults--features)
- [Форматы иерархии](#форматы-иерархии)
- [Схема `panel-data`](#схема-panel-data)
- [Поля данных](#поля-данных)
@ -56,7 +57,8 @@ ecosystem/
├── index.html # Разметка страницы
├── styles.css # Стили, темы, адаптив
├── script.js # Логика, рендер, примеры структур
├── settings.json # Источник данных (URL или inline)
├── settings.json # Данные + пресет UI организации
├── settings-top-bar.example.json # Пример пресета «шапка сверху»
├── panel-data.json # Данные навигации (рабочий файл)
├── examples/ # Готовые демо-страницы (см. examples/README.md)
│ ├── index.html # Оглавление примеров
@ -108,6 +110,69 @@ ecosystem/
При ошибке загрузки или невалидном JSON на странице показывается экран с описанием проблемы.
#### Пресет организации (`defaults` + `features`)
В `settings.json` можно задать дефолтный вид панели для организации и ограничить, что пользователь может менять в ⚙.
```json
{
"dataUrl": "./panel-data.json",
"defaults": {
"navLayout": "sidebar",
"theme": "dark",
"view": "cards",
"accent": "violet",
"showNumbers": false,
"showTreeTags": true,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Grafana, S3, журнал, сервер..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": true,
"allowTreeOptions": true,
"allowSidebarResize": true,
"allowThemeChange": true,
"allowAccentChange": true
}
}
```
| `defaults` | Варианты | Описание |
|------------|----------|----------|
| `navLayout` | `"sidebar"`, `"top"` | `"sidebar"` — дерево слева; `"top"` — шапка с названием и поиском, без дерева |
| `theme` | `"dark"`, `"light"` | Стартовая цветовая тема |
| `view` | `"cards"`, `"list"`, `"compact-cards"`, `"compact-list"` | Режим отображения каталога |
| `accent` | `"violet"`, `"cyan"`, `"lime"`, `"amber"`, `"custom"` | Пресет акцента; при `"custom"` используются поля градиента ниже |
| `customAccentFrom` | `#rrggbb` | Цвет «от» для `accent: "custom"` (по умолчанию `#8b5cf6`) |
| `customAccentTo` | `#rrggbb` | Цвет «до» для `accent: "custom"` (по умолчанию `#22d3ee`) |
| `customAccentAngle` | `0`–`360` | Угол градиента в градусах (по умолчанию `135`) |
| `showNumbers` | `true`, `false` | Показывать номера разделов, подразделов и элементов |
| `showTreeTags` | `true`, `false` | Теги рядом с элементами в дереве (взаимоисключимо с `treeTwoColumn`) |
| `treeTwoColumn` | `true`, `false` | Элементы папок дерева в 2 колонки (взаимоисключимо с `showTreeTags`) |
| `autoCollapseSearch` | `true`, `false` | При поиске сворачивать ветки дерева без совпадений |
| `sidebarWidth` | `260`–`560` (`760` при `treeTwoColumn: true`) | Ширина боковой панели в px; игнорируется при `navLayout: "top"` |
| `searchPlaceholder` | произвольная строка | Текст-подсказка в поле поиска |
| `features` | Варианты | Описание |
|------------|----------|----------|
| `allowNavLayoutChange` | `true`, `false` | Переключатель «Сбоку / Сверху» |
| `allowSearch` | `true`, `false` | Поле поиска |
| `allowViewChange` | `true`, `false` | Режимы каталога |
| `allowShowNumbers` | `true`, `false` | Нумерация |
| `allowTreeOptions` | `true`, `false` | Теги, 2 колонки, авто-раскрытие дерева |
| `allowSidebarResize` | `true`, `false` | Drag-resize боковой панели |
| `allowThemeChange` | `true`, `false` | Светлая / тёмная тема |
| `allowAccentChange` | `true`, `false` | Пресеты и свой градиент |
Пользовательские изменения сохраняются в `localStorage`, но заблокированные `features` всегда возвращаются к `defaults`.
Пример пресета «только шапка»: [`settings-top-bar.example.json`](settings-top-bar.example.json) — `navLayout: "top"`, дерево и resize отключены.
### Форматы иерархии
Панель поддерживает три базовых формата. Старый **section → group → item** работает без изменений.
@ -257,17 +322,18 @@ npx serve .
## Настройки интерфейса (⚙)
Доступны в popup-окне и сохраняются в `localStorage` (`ecosystem-panel-preferences`):
Popup сгруппирован по разделам. Доступные блоки зависят от `features` в `settings.json`. Изменения пользователя сохраняются в `localStorage` (`ecosystem-panel-preferences`).
| Настройка | Описание |
|-----------|----------|
| **Тема** | Светлая / тёмная |
| **Вид** | Плитки, список, компакт плитки, компакт список |
| **Нумерация** | Показывать номера разделов, подразделов и элементов |
| **Теги дерева** | Поисковые теги рядом с элементами в дереве |
| **2 колонки в дереве** | Элементы папок в 2 колонки (взаимоисключимо с тегами) |
| **Авто раскрывать поиск** | При поиске сворачивать ветки без совпадений |
| **Акцент** | violet, cyan, lime, amber или свой градиент |
| Раздел | Настройки |
|--------|-----------|
| **Макет** | Панель навигации: **Сбоку** (дерево + поиск) или **Сверху** (только название и поиск) |
| **Каталог** | Вид карточек, нумерация |
| **Дерево** | Теги, 2 колонки, авто-раскрытие поиска (только при `navLayout: sidebar`) |
| **Оформление** | Тема, акцент, свой градиент |
### Режим «Сверху»
Компактная шапка над каталогом: заголовок панели, поиск и ⚙. Дерево навигации скрыто — переход только через карточки каталога. Подходит для простых порталов и когда дерево избыточно.
### Свой градиент

View File

@ -51,7 +51,7 @@ npx serve .
| Файл | Назначение |
|------|------------|
| `index.html` | Страница панели, подключает `../shared/` |
| `settings.json` | Указывает `./panel-data.json` |
| `settings.json` | Указывает `./panel-data.json` и пресет UI (`defaults`, `features`) |
| `panel-data.json` | Демо-данные выбранного формата |
## Обновление shared/

View File

@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
@ -17,13 +17,21 @@
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<!-- Декоративные фоновые пятна. Они не участвуют в логике и нужны только для глубины фона. -->
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<!--
Основная сетка приложения:
слева sticky-меню с деревом навигации, справа каталог карточек.
Ширина левой колонки управляется CSS-переменной --sidebar-width и ресайзером.
-->
<div class="app-layout">
<!-- Боковая панель: поиск, настройки и дерево навигации по всей структуре. -->
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<!-- Шестеренка открывает fixed popup с настройками внешнего вида и поведения. -->
<button
class="settings-button"
type="button"
@ -36,7 +44,8 @@
<p class="eyebrow" id="page-title">Пример: плоский список</p>
<label class="search-field">
<!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
<label class="search-field" data-search-field>
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
@ -45,10 +54,12 @@
/>
</label>
<!-- Дерево генерируется из ecosystemSections в script.js. -->
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<!-- Вертикальная ручка для изменения ширины бокового меню мышью/указателем. -->
<div
class="sidebar-resizer"
role="separator"
@ -57,8 +68,18 @@
data-sidebar-resizer
></div>
<!-- Затемнение страницы за popup-настройками. -->
<div class="settings-overlay" data-settings-overlay></div>
<!--
Popup настроек:
- тема;
- режим отображения карточек;
- видимость нумерации;
- видимость тегов в дереве;
- авто-раскрытие дерева при поиске;
- цветовой акцент.
-->
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
@ -70,28 +91,57 @@
</button>
</div>
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
<section class="settings-section" data-settings-section="layout">
<p class="settings-section-title">Макет</p>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
<span>Панель навигации</span>
<button class="segmented is-active" type="button" data-nav-layout="sidebar">
Сбоку
</button>
<button class="segmented" type="button" data-nav-layout="top">
Сверху
</button>
</div>
<p class="settings-section-hint">
Режим «Сверху» — компактная шапка с названием и поиском, без дерева.
</p>
</section>
<div class="control-group view-group">
<section class="settings-section" data-settings-section="catalog">
<p class="settings-section-title">Каталог</p>
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
<div class="control-group view-group" data-feature="allowViewChange">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
<button class="segmented" type="button" data-view="compact-cards">
Компакт плитки
</button>
<button class="segmented" type="button" data-view="compact-list">
Компакт список
</button>
</div>
<label class="toggle-row">
<!-- Показывать номера разделов, подразделов и элементов. -->
<label class="toggle-row" data-feature="allowShowNumbers">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
</section>
<section class="settings-section" data-settings-section="tree">
<p class="settings-section-title">Дерево</p>
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
@ -100,6 +150,7 @@
<input type="checkbox" data-show-tree-tags />
</label>
<!-- Элементы внутри папок дерева отображаются в две колонки без тегов. -->
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
@ -108,6 +159,7 @@
<input type="checkbox" data-tree-two-column />
</label>
<!-- Управляет тем, раскрывает ли поиск только ветки с совпадениями. -->
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
@ -115,39 +167,95 @@
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
</section>
<div class="control-group accent-group">
<section class="settings-section" data-settings-section="appearance">
<p class="settings-section-title">Оформление</p>
<div class="control-group" data-feature="allowThemeChange">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<!-- Выбор акцентного цвета интерфейса. -->
<div class="control-group accent-group" data-feature="allowAccentChange">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
<button
class="accent-swatch accent-swatch-custom"
type="button"
data-accent="custom"
aria-label="Свой градиент"
title="Свой градиент"
>
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<!-- Настройки пользовательского linear-gradient акцента. -->
<div class="accent-custom-panel" data-accent-custom-panel data-feature="allowAccentChange" hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<input
type="range"
min="0"
max="360"
step="1"
value="135"
data-custom-accent-angle
/>
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</section>
</div>
<!-- Каталог карточек генерируется из ecosystemSections в script.js. -->
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<!-- Шаблон ошибки загрузки settings.json / panel data. -->
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
@ -156,6 +264,7 @@
</div>
</template>
<!-- Шаблон состояния, когда поиск не нашел ни одного элемента. -->
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
@ -166,11 +275,6 @@
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
<script src="../shared/script.js" data-ecosystem-panel data-asset-base="../shared/" defer></script>
</body>
</html>

View File

@ -1,4 +1,26 @@
{
"dataUrl": "./panel-data.json",
"data": null
"data": null,
"defaults": {
"navLayout": "sidebar",
"theme": "dark",
"view": "cards",
"accent": "violet",
"showNumbers": false,
"showTreeTags": true,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Grafana, S3, журнал, сервер..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": true,
"allowTreeOptions": true,
"allowSidebarResize": true,
"allowThemeChange": true,
"allowAccentChange": true
}
}

View File

@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
@ -17,13 +17,21 @@
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<!-- Декоративные фоновые пятна. Они не участвуют в логике и нужны только для глубины фона. -->
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<!--
Основная сетка приложения:
слева sticky-меню с деревом навигации, справа каталог карточек.
Ширина левой колонки управляется CSS-переменной --sidebar-width и ресайзером.
-->
<div class="app-layout">
<!-- Боковая панель: поиск, настройки и дерево навигации по всей структуре. -->
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<!-- Шестеренка открывает fixed popup с настройками внешнего вида и поведения. -->
<button
class="settings-button"
type="button"
@ -36,7 +44,8 @@
<p class="eyebrow" id="page-title">Пример: section → group → item</p>
<label class="search-field">
<!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
<label class="search-field" data-search-field>
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
@ -45,10 +54,12 @@
/>
</label>
<!-- Дерево генерируется из ecosystemSections в script.js. -->
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<!-- Вертикальная ручка для изменения ширины бокового меню мышью/указателем. -->
<div
class="sidebar-resizer"
role="separator"
@ -57,8 +68,18 @@
data-sidebar-resizer
></div>
<!-- Затемнение страницы за popup-настройками. -->
<div class="settings-overlay" data-settings-overlay></div>
<!--
Popup настроек:
- тема;
- режим отображения карточек;
- видимость нумерации;
- видимость тегов в дереве;
- авто-раскрытие дерева при поиске;
- цветовой акцент.
-->
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
@ -70,28 +91,57 @@
</button>
</div>
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
<section class="settings-section" data-settings-section="layout">
<p class="settings-section-title">Макет</p>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
<span>Панель навигации</span>
<button class="segmented is-active" type="button" data-nav-layout="sidebar">
Сбоку
</button>
<button class="segmented" type="button" data-nav-layout="top">
Сверху
</button>
</div>
<p class="settings-section-hint">
Режим «Сверху» — компактная шапка с названием и поиском, без дерева.
</p>
</section>
<div class="control-group view-group">
<section class="settings-section" data-settings-section="catalog">
<p class="settings-section-title">Каталог</p>
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
<div class="control-group view-group" data-feature="allowViewChange">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
<button class="segmented" type="button" data-view="compact-cards">
Компакт плитки
</button>
<button class="segmented" type="button" data-view="compact-list">
Компакт список
</button>
</div>
<label class="toggle-row">
<!-- Показывать номера разделов, подразделов и элементов. -->
<label class="toggle-row" data-feature="allowShowNumbers">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
</section>
<section class="settings-section" data-settings-section="tree">
<p class="settings-section-title">Дерево</p>
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
@ -100,6 +150,7 @@
<input type="checkbox" data-show-tree-tags />
</label>
<!-- Элементы внутри папок дерева отображаются в две колонки без тегов. -->
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
@ -108,6 +159,7 @@
<input type="checkbox" data-tree-two-column />
</label>
<!-- Управляет тем, раскрывает ли поиск только ветки с совпадениями. -->
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
@ -115,39 +167,95 @@
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
</section>
<div class="control-group accent-group">
<section class="settings-section" data-settings-section="appearance">
<p class="settings-section-title">Оформление</p>
<div class="control-group" data-feature="allowThemeChange">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<!-- Выбор акцентного цвета интерфейса. -->
<div class="control-group accent-group" data-feature="allowAccentChange">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
<button
class="accent-swatch accent-swatch-custom"
type="button"
data-accent="custom"
aria-label="Свой градиент"
title="Свой градиент"
>
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<!-- Настройки пользовательского linear-gradient акцента. -->
<div class="accent-custom-panel" data-accent-custom-panel data-feature="allowAccentChange" hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<input
type="range"
min="0"
max="360"
step="1"
value="135"
data-custom-accent-angle
/>
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</section>
</div>
<!-- Каталог карточек генерируется из ecosystemSections в script.js. -->
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<!-- Шаблон ошибки загрузки settings.json / panel data. -->
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
@ -156,6 +264,7 @@
</div>
</template>
<!-- Шаблон состояния, когда поиск не нашел ни одного элемента. -->
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
@ -166,11 +275,6 @@
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
<script src="../shared/script.js" data-ecosystem-panel data-asset-base="../shared/" defer></script>
</body>
</html>

View File

@ -1,4 +1,26 @@
{
"dataUrl": "./panel-data.json",
"data": null
"data": null,
"defaults": {
"navLayout": "sidebar",
"theme": "dark",
"view": "cards",
"accent": "violet",
"showNumbers": false,
"showTreeTags": true,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Grafana, S3, журнал, сервер..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": true,
"allowTreeOptions": true,
"allowSidebarResize": true,
"allowThemeChange": true,
"allowAccentChange": true
}
}

View File

@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
@ -17,13 +17,21 @@
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<!-- Декоративные фоновые пятна. Они не участвуют в логике и нужны только для глубины фона. -->
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<!--
Основная сетка приложения:
слева sticky-меню с деревом навигации, справа каталог карточек.
Ширина левой колонки управляется CSS-переменной --sidebar-width и ресайзером.
-->
<div class="app-layout">
<!-- Боковая панель: поиск, настройки и дерево навигации по всей структуре. -->
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<!-- Шестеренка открывает fixed popup с настройками внешнего вида и поведения. -->
<button
class="settings-button"
type="button"
@ -36,7 +44,8 @@
<p class="eyebrow" id="page-title">Пример: смешанный раздел</p>
<label class="search-field">
<!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
<label class="search-field" data-search-field>
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
@ -45,10 +54,12 @@
/>
</label>
<!-- Дерево генерируется из ecosystemSections в script.js. -->
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<!-- Вертикальная ручка для изменения ширины бокового меню мышью/указателем. -->
<div
class="sidebar-resizer"
role="separator"
@ -57,8 +68,18 @@
data-sidebar-resizer
></div>
<!-- Затемнение страницы за popup-настройками. -->
<div class="settings-overlay" data-settings-overlay></div>
<!--
Popup настроек:
- тема;
- режим отображения карточек;
- видимость нумерации;
- видимость тегов в дереве;
- авто-раскрытие дерева при поиске;
- цветовой акцент.
-->
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
@ -70,28 +91,57 @@
</button>
</div>
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
<section class="settings-section" data-settings-section="layout">
<p class="settings-section-title">Макет</p>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
<span>Панель навигации</span>
<button class="segmented is-active" type="button" data-nav-layout="sidebar">
Сбоку
</button>
<button class="segmented" type="button" data-nav-layout="top">
Сверху
</button>
</div>
<p class="settings-section-hint">
Режим «Сверху» — компактная шапка с названием и поиском, без дерева.
</p>
</section>
<div class="control-group view-group">
<section class="settings-section" data-settings-section="catalog">
<p class="settings-section-title">Каталог</p>
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
<div class="control-group view-group" data-feature="allowViewChange">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
<button class="segmented" type="button" data-view="compact-cards">
Компакт плитки
</button>
<button class="segmented" type="button" data-view="compact-list">
Компакт список
</button>
</div>
<label class="toggle-row">
<!-- Показывать номера разделов, подразделов и элементов. -->
<label class="toggle-row" data-feature="allowShowNumbers">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
</section>
<section class="settings-section" data-settings-section="tree">
<p class="settings-section-title">Дерево</p>
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
@ -100,6 +150,7 @@
<input type="checkbox" data-show-tree-tags />
</label>
<!-- Элементы внутри папок дерева отображаются в две колонки без тегов. -->
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
@ -108,6 +159,7 @@
<input type="checkbox" data-tree-two-column />
</label>
<!-- Управляет тем, раскрывает ли поиск только ветки с совпадениями. -->
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
@ -115,39 +167,95 @@
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
</section>
<div class="control-group accent-group">
<section class="settings-section" data-settings-section="appearance">
<p class="settings-section-title">Оформление</p>
<div class="control-group" data-feature="allowThemeChange">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<!-- Выбор акцентного цвета интерфейса. -->
<div class="control-group accent-group" data-feature="allowAccentChange">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
<button
class="accent-swatch accent-swatch-custom"
type="button"
data-accent="custom"
aria-label="Свой градиент"
title="Свой градиент"
>
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<!-- Настройки пользовательского linear-gradient акцента. -->
<div class="accent-custom-panel" data-accent-custom-panel data-feature="allowAccentChange" hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<input
type="range"
min="0"
max="360"
step="1"
value="135"
data-custom-accent-angle
/>
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</section>
</div>
<!-- Каталог карточек генерируется из ecosystemSections в script.js. -->
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<!-- Шаблон ошибки загрузки settings.json / panel data. -->
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
@ -156,6 +264,7 @@
</div>
</template>
<!-- Шаблон состояния, когда поиск не нашел ни одного элемента. -->
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
@ -166,11 +275,6 @@
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
<script src="../shared/script.js" data-ecosystem-panel data-asset-base="../shared/" defer></script>
</body>
</html>

View File

@ -1,4 +1,26 @@
{
"dataUrl": "./panel-data.json",
"data": null
"data": null,
"defaults": {
"navLayout": "sidebar",
"theme": "dark",
"view": "cards",
"accent": "violet",
"showNumbers": false,
"showTreeTags": true,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Grafana, S3, журнал, сервер..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": true,
"allowTreeOptions": true,
"allowSidebarResize": true,
"allowThemeChange": true,
"allowAccentChange": true
}
}

View File

@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
@ -17,13 +17,21 @@
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<!-- Декоративные фоновые пятна. Они не участвуют в логике и нужны только для глубины фона. -->
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<!--
Основная сетка приложения:
слева sticky-меню с деревом навигации, справа каталог карточек.
Ширина левой колонки управляется CSS-переменной --sidebar-width и ресайзером.
-->
<div class="app-layout">
<!-- Боковая панель: поиск, настройки и дерево навигации по всей структуре. -->
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<!-- Шестеренка открывает fixed popup с настройками внешнего вида и поведения. -->
<button
class="settings-button"
type="button"
@ -36,7 +44,8 @@
<p class="eyebrow" id="page-title">Пример: section → item</p>
<label class="search-field">
<!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
<label class="search-field" data-search-field>
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
@ -45,10 +54,12 @@
/>
</label>
<!-- Дерево генерируется из ecosystemSections в script.js. -->
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<!-- Вертикальная ручка для изменения ширины бокового меню мышью/указателем. -->
<div
class="sidebar-resizer"
role="separator"
@ -57,8 +68,18 @@
data-sidebar-resizer
></div>
<!-- Затемнение страницы за popup-настройками. -->
<div class="settings-overlay" data-settings-overlay></div>
<!--
Popup настроек:
- тема;
- режим отображения карточек;
- видимость нумерации;
- видимость тегов в дереве;
- авто-раскрытие дерева при поиске;
- цветовой акцент.
-->
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
@ -70,28 +91,57 @@
</button>
</div>
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
<section class="settings-section" data-settings-section="layout">
<p class="settings-section-title">Макет</p>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
<span>Панель навигации</span>
<button class="segmented is-active" type="button" data-nav-layout="sidebar">
Сбоку
</button>
<button class="segmented" type="button" data-nav-layout="top">
Сверху
</button>
</div>
<p class="settings-section-hint">
Режим «Сверху» — компактная шапка с названием и поиском, без дерева.
</p>
</section>
<div class="control-group view-group">
<section class="settings-section" data-settings-section="catalog">
<p class="settings-section-title">Каталог</p>
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
<div class="control-group view-group" data-feature="allowViewChange">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
<button class="segmented" type="button" data-view="compact-cards">
Компакт плитки
</button>
<button class="segmented" type="button" data-view="compact-list">
Компакт список
</button>
</div>
<label class="toggle-row">
<!-- Показывать номера разделов, подразделов и элементов. -->
<label class="toggle-row" data-feature="allowShowNumbers">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
</section>
<section class="settings-section" data-settings-section="tree">
<p class="settings-section-title">Дерево</p>
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
@ -100,6 +150,7 @@
<input type="checkbox" data-show-tree-tags />
</label>
<!-- Элементы внутри папок дерева отображаются в две колонки без тегов. -->
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
@ -108,6 +159,7 @@
<input type="checkbox" data-tree-two-column />
</label>
<!-- Управляет тем, раскрывает ли поиск только ветки с совпадениями. -->
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
@ -115,39 +167,95 @@
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
</section>
<div class="control-group accent-group">
<section class="settings-section" data-settings-section="appearance">
<p class="settings-section-title">Оформление</p>
<div class="control-group" data-feature="allowThemeChange">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<!-- Выбор акцентного цвета интерфейса. -->
<div class="control-group accent-group" data-feature="allowAccentChange">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
<button
class="accent-swatch accent-swatch-custom"
type="button"
data-accent="custom"
aria-label="Свой градиент"
title="Свой градиент"
>
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<!-- Настройки пользовательского linear-gradient акцента. -->
<div class="accent-custom-panel" data-accent-custom-panel data-feature="allowAccentChange" hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<input
type="range"
min="0"
max="360"
step="1"
value="135"
data-custom-accent-angle
/>
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</section>
</div>
<!-- Каталог карточек генерируется из ecosystemSections в script.js. -->
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<!-- Шаблон ошибки загрузки settings.json / panel data. -->
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
@ -156,6 +264,7 @@
</div>
</template>
<!-- Шаблон состояния, когда поиск не нашел ни одного элемента. -->
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
@ -166,11 +275,6 @@
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
<script src="../shared/script.js" data-ecosystem-panel data-asset-base="../shared/" defer></script>
</body>
</html>

View File

@ -1,4 +1,26 @@
{
"dataUrl": "./panel-data.json",
"data": null
"data": null,
"defaults": {
"navLayout": "sidebar",
"theme": "dark",
"view": "cards",
"accent": "violet",
"showNumbers": false,
"showTreeTags": true,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Grafana, S3, журнал, сервер..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": true,
"allowTreeOptions": true,
"allowSidebarResize": true,
"allowThemeChange": true,
"allowAccentChange": true
}
}

View File

@ -64,34 +64,54 @@ const defaultPreferences = {
customAccentFrom: "#8b5cf6",
customAccentTo: "#22d3ee",
customAccentAngle: 135,
navLayout: "sidebar",
showNumbers: false,
showTreeTags: true,
treeTwoColumn: false,
autoCollapseSearch: true,
sidebarWidth: 390,
searchPlaceholder: "Grafana, S3, журнал, сервер...",
};
const defaultPanelFeatures = {
allowNavLayoutChange: true,
allowSearch: true,
allowViewChange: true,
allowShowNumbers: true,
allowTreeOptions: true,
allowSidebarResize: true,
allowThemeChange: true,
allowAccentChange: true,
};
const availableNavLayouts = ["sidebar", "top"];
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const availableAccents = ["violet", "cyan", "lime", "amber", "custom"];
// ============================================================================
// Runtime state and DOM references
// ============================================================================
// preferences - текущие настройки интерфейса, объединенные с localStorage.
const preferences = { ...defaultPreferences, ...readPreferences() };
// preferences - текущие настройки интерфейса; итоговые значения собираются в bootstrap().
let panelDefaults = { ...defaultPreferences };
let panelFeatures = { ...defaultPanelFeatures };
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 searchField = document.querySelector("[data-search-field]");
const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]");
const settingsOverlay = document.querySelector("[data-settings-overlay]");
const settingsClose = document.querySelector("[data-settings-close]");
const themeButtons = document.querySelectorAll("[data-theme]");
const navLayoutButtons = document.querySelectorAll("[data-nav-layout]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const accentCustomPanel = document.querySelector("[data-accent-custom-panel]");
@ -104,6 +124,7 @@ const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const treeTwoColumnInput = document.querySelector("[data-tree-two-column]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const 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");
@ -115,7 +136,11 @@ bootstrap();
async function bootstrap() {
try {
const panelData = await loadPanelConfig();
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;
@ -192,15 +217,40 @@ document.addEventListener("click", (event) => {
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 (preferences.navLayout === "sidebar") {
renderNavigationTree();
}
savePreferences(preferences);
});
});
// Переключение режима отображения каталога.
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowViewChange) {
return;
}
preferences.view = button.dataset.view;
applyPreferences(preferences);
savePreferences(preferences);
@ -210,6 +260,10 @@ viewButtons.forEach((button) => {
// Выбор цветового акцента из пресетов или пользовательского градиента.
accentButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.accent = button.dataset.accent;
applyPreferences(preferences);
savePreferences(preferences);
@ -218,6 +272,10 @@ accentButtons.forEach((button) => {
// Изменение параметров своего градиента сразу переключает accent на custom.
function handleCustomAccentChange() {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.customAccentFrom = customAccentFromInput.value;
preferences.customAccentTo = customAccentToInput.value;
preferences.customAccentAngle = Number(customAccentAngleInput.value);
@ -232,6 +290,10 @@ customAccentAngleInput.addEventListener("input", handleCustomAccentChange);
// Показывает/скрывает номера в секциях, группах и карточках.
showNumbersInput.addEventListener("change", () => {
if (!panelFeatures.allowShowNumbers) {
return;
}
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
@ -240,6 +302,10 @@ showNumbersInput.addEventListener("change", () => {
// Показывает/скрывает поисковые теги в файловом дереве.
// Взаимоисключимо с двухколоночным режимом элементов папок.
showTreeTagsInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || preferences.navLayout === "top") {
return;
}
preferences.showTreeTags = showTreeTagsInput.checked;
if (preferences.showTreeTags) {
preferences.treeTwoColumn = false;
@ -251,6 +317,10 @@ showTreeTagsInput.addEventListener("change", () => {
// Компактный двухколоночный режим элементов внутри папок дерева.
// Взаимоисключимо с отображением тегов.
treeTwoColumnInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || preferences.navLayout === "top") {
return;
}
preferences.treeTwoColumn = treeTwoColumnInput.checked;
if (preferences.treeTwoColumn) {
preferences.showTreeTags = false;
@ -264,6 +334,10 @@ treeTwoColumnInput.addEventListener("change", () => {
// Включает/выключает авто-раскрытие веток дерева при поиске.
autoCollapseSearchInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || preferences.navLayout === "top") {
return;
}
preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
applyPreferences(preferences);
renderNavigationTree();
@ -273,6 +347,10 @@ autoCollapseSearchInput.addEventListener("change", () => {
// Drag-resize бокового меню.
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
sidebarResizer.addEventListener("pointerdown", (event) => {
if (preferences.navLayout === "top" || !panelFeatures.allowSidebarResize) {
return;
}
event.preventDefault();
document.body.classList.add("is-resizing-sidebar");
sidebarResizer.setPointerCapture(event.pointerId);
@ -301,8 +379,7 @@ sidebarResizer.addEventListener("pointerdown", (event) => {
// ============================================================================
// Panel config loading
// ============================================================================
async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
const settings = await fetchJson(settingsPath);
async function loadPanelDataFromSettings(settings) {
const dataUrl = typeof settings.dataUrl === "string" ? settings.dataUrl.trim() : "";
let panelSource = settings.data;
@ -317,6 +394,117 @@ async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
return normalizePanelData(panelSource);
}
function applyOrganizationConfig(settings) {
panelDefaults = normalizePreferences({
...defaultPreferences,
...(settings?.defaults && typeof settings.defaults === "object" ? settings.defaults : {}),
});
panelFeatures = {
...defaultPanelFeatures,
...(settings?.features && typeof settings.features === "object" ? settings.features : {}),
};
const placeholder =
typeof settings?.defaults?.searchPlaceholder === "string"
? settings.defaults.searchPlaceholder.trim()
: panelDefaults.searchPlaceholder;
if (searchInput && placeholder) {
searchInput.placeholder = placeholder;
}
}
function normalizePreferences(raw) {
const next = { ...raw };
if (!availableViews.includes(next.view)) {
next.view = defaultPreferences.view;
}
if (!availableAccents.includes(next.accent)) {
next.accent = defaultPreferences.accent;
}
if (!availableNavLayouts.includes(next.navLayout)) {
next.navLayout = defaultPreferences.navLayout;
}
next.customAccentFrom =
normalizeHexColor(next.customAccentFrom) || defaultPreferences.customAccentFrom;
next.customAccentTo = normalizeHexColor(next.customAccentTo) || defaultPreferences.customAccentTo;
next.customAccentAngle = clamp(
Number(next.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
next.showNumbers = Boolean(next.showNumbers);
next.showTreeTags = Boolean(next.showTreeTags);
next.treeTwoColumn = Boolean(next.treeTwoColumn);
next.autoCollapseSearch = Boolean(next.autoCollapseSearch);
next.sidebarWidth = clamp(
Number(next.sidebarWidth) || defaultPreferences.sidebarWidth,
260,
getSidebarMaxWidth(next),
);
if (next.treeTwoColumn) {
next.showTreeTags = false;
} else if (next.showTreeTags) {
next.treeTwoColumn = false;
}
if (next.navLayout === "top") {
next.treeTwoColumn = false;
next.showTreeTags = false;
}
return next;
}
function enforceFeatureLocks(rawPreferences) {
const next = normalizePreferences(rawPreferences);
if (!panelFeatures.allowNavLayoutChange) {
next.navLayout = panelDefaults.navLayout;
}
if (!panelFeatures.allowThemeChange) {
next.theme = panelDefaults.theme;
}
if (!panelFeatures.allowViewChange) {
next.view = panelDefaults.view;
}
if (!panelFeatures.allowAccentChange) {
next.accent = panelDefaults.accent;
next.customAccentFrom = panelDefaults.customAccentFrom;
next.customAccentTo = panelDefaults.customAccentTo;
next.customAccentAngle = panelDefaults.customAccentAngle;
}
if (!panelFeatures.allowShowNumbers) {
next.showNumbers = panelDefaults.showNumbers;
}
if (!panelFeatures.allowTreeOptions || next.navLayout === "top") {
next.showTreeTags = false;
next.treeTwoColumn = false;
next.autoCollapseSearch = panelDefaults.autoCollapseSearch;
}
if (!panelFeatures.allowSidebarResize || next.navLayout === "top") {
next.sidebarWidth = panelDefaults.sidebarWidth;
}
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);
@ -691,6 +879,11 @@ function matchesText(values, query) {
// Строит левое файловое дерево из той же структуры данных, что и каталог.
// Это важно: дерево и карточки всегда остаются синхронизированными.
function renderNavigationTree() {
if (preferences.navLayout === "top") {
navTree.replaceChildren();
return;
}
const query = normalize(searchInput.value);
const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch);
const fragment = document.createDocumentFragment();
@ -877,65 +1070,95 @@ function attachCardInteractions() {
// - класс режима отображения каталога;
// - состояния toggle-контролов в настройках.
function applyPreferences(nextPreferences) {
if (!availableViews.includes(nextPreferences.view)) {
nextPreferences.view = defaultPreferences.view;
}
const lockedPreferences = enforceFeatureLocks(nextPreferences);
Object.assign(preferences, lockedPreferences);
const activePreferences = preferences;
if (!availableAccents.includes(nextPreferences.accent)) {
nextPreferences.accent = defaultPreferences.accent;
}
nextPreferences.customAccentFrom = normalizeHexColor(nextPreferences.customAccentFrom) || defaultPreferences.customAccentFrom;
nextPreferences.customAccentTo = normalizeHexColor(nextPreferences.customAccentTo) || defaultPreferences.customAccentTo;
nextPreferences.customAccentAngle = clamp(
Number(nextPreferences.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
if (nextPreferences.treeTwoColumn) {
nextPreferences.showTreeTags = false;
} else if (nextPreferences.showTreeTags) {
nextPreferences.treeTwoColumn = false;
}
document.documentElement.dataset.theme = nextPreferences.theme;
document.documentElement.dataset.accent = nextPreferences.accent;
applyAccentVariables(nextPreferences);
document.documentElement.dataset.theme = activePreferences.theme;
document.documentElement.dataset.accent = activePreferences.accent;
document.documentElement.dataset.navLayout =
activePreferences.navLayout === "top" ? "top" : "sidebar";
applyAccentVariables(activePreferences);
document.documentElement.style.setProperty(
"--sidebar-width",
`${clamp(
Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
Number(activePreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
260,
getSidebarMaxWidth(nextPreferences),
getSidebarMaxWidth(activePreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--shell-max-width",
nextPreferences.treeTwoColumn ? "1920px" : "1560px",
activePreferences.treeTwoColumn ? "1920px" : "1560px",
);
document.documentElement.style.setProperty(
"--sidebar-max-width",
`${getSidebarMaxWidth(nextPreferences)}px`,
`${getSidebarMaxWidth(activePreferences)}px`,
);
catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
catalog.classList.add(`${nextPreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers));
document.documentElement.dataset.showTreeTags = String(Boolean(nextPreferences.showTreeTags));
document.documentElement.dataset.treeTwoColumn = String(Boolean(nextPreferences.treeTwoColumn));
showNumbersInput.checked = Boolean(nextPreferences.showNumbers);
showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags);
treeTwoColumnInput.checked = Boolean(nextPreferences.treeTwoColumn);
autoCollapseSearchInput.checked = Boolean(nextPreferences.autoCollapseSearch);
customAccentFromInput.value = nextPreferences.customAccentFrom;
customAccentToInput.value = nextPreferences.customAccentTo;
customAccentAngleInput.value = String(nextPreferences.customAccentAngle);
customAccentAngleValue.textContent = `${nextPreferences.customAccentAngle}°`;
accentCustomPanel.hidden = nextPreferences.accent !== "custom";
updateCustomAccentPreview(nextPreferences);
setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent);
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.treeTwoColumn = String(Boolean(activePreferences.treeTwoColumn));
showNumbersInput.checked = Boolean(activePreferences.showNumbers);
showTreeTagsInput.checked = Boolean(activePreferences.showTreeTags);
treeTwoColumnInput.checked = Boolean(activePreferences.treeTwoColumn);
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);
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 = activePreferences.navLayout === "top";
}
if (sidebarResizer) {
sidebarResizer.hidden =
activePreferences.navLayout === "top" || !panelFeatures.allowSidebarResize;
}
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;
});
}
function isSettingsSectionVisible(sectionName, activePreferences) {
switch (sectionName) {
case "layout":
return panelFeatures.allowNavLayoutChange;
case "catalog":
return panelFeatures.allowViewChange || panelFeatures.allowShowNumbers;
case "tree":
return panelFeatures.allowTreeOptions && activePreferences.navLayout !== "top";
case "appearance":
return panelFeatures.allowThemeChange || panelFeatures.allowAccentChange;
default:
return true;
}
}
// Применяет CSS-переменные акцента для пресетов или пользовательского градиента.

View File

@ -143,6 +143,48 @@ a {
align-items: start;
}
/* Компактная шапка: название + поиск, без дерева и resizer. */
html[data-nav-layout="top"] .app-layout,
.app-layout[data-nav-layout="top"] {
grid-template-columns: 1fr;
}
html[data-nav-layout="top"] .sidebar-resizer {
display: none;
}
html[data-nav-layout="top"] .side-panel {
position: static;
}
html[data-nav-layout="top"] .side-panel .hero-content {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 12px 16px;
max-height: none;
min-height: auto;
overflow: visible;
padding: 14px 58px 14px 18px;
}
html[data-nav-layout="top"] .side-panel .hero-content > .eyebrow {
margin: 0;
flex: 0 1 auto;
max-width: min(100%, 360px);
}
html[data-nav-layout="top"] .search-field {
margin-top: 0;
flex: 1 1 220px;
max-width: min(100%, 560px);
}
html[data-nav-layout="top"] .nav-tree {
display: none;
}
/* ==========================================================================
04. Sidebar Resizer
--------------------------------------------------------------------------
@ -410,6 +452,31 @@ html[data-tree-two-column="true"] .tree-node.tree-file small {
line-height: 1;
}
.settings-section {
display: grid;
gap: 10px;
}
.settings-section[hidden] {
display: none;
}
.settings-section-title {
margin: 0;
color: var(--text);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.settings-section-hint {
margin: 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.45;
}
/* ==========================================================================
07. Search Field And Control Inputs
--------------------------------------------------------------------------

View File

@ -45,7 +45,7 @@
<p class="eyebrow" id="page-title">Панель экосистемы</p>
<!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
<label class="search-field">
<label class="search-field" data-search-field>
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
@ -92,18 +92,27 @@
</div>
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
<section class="settings-section" data-settings-section="layout">
<p class="settings-section-title">Макет</p>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
<span>Панель навигации</span>
<button class="segmented is-active" type="button" data-nav-layout="sidebar">
Сбоку
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
<button class="segmented" type="button" data-nav-layout="top">
Сверху
</button>
</div>
<p class="settings-section-hint">
Режим «Сверху» — компактная шапка с названием и поиском, без дерева.
</p>
</section>
<section class="settings-section" data-settings-section="catalog">
<p class="settings-section-title">Каталог</p>
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
<div class="control-group view-group">
<div class="control-group view-group" data-feature="allowViewChange">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
@ -120,13 +129,17 @@
</div>
<!-- Показывать номера разделов, подразделов и элементов. -->
<label class="toggle-row">
<label class="toggle-row" data-feature="allowShowNumbers">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
</section>
<section class="settings-section" data-settings-section="tree">
<p class="settings-section-title">Дерево</p>
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
<label class="toggle-row">
@ -154,9 +167,23 @@
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
</section>
<section class="settings-section" data-settings-section="appearance">
<p class="settings-section-title">Оформление</p>
<div class="control-group" data-feature="allowThemeChange">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<!-- Выбор акцентного цвета интерфейса. -->
<div class="control-group accent-group">
<div class="control-group accent-group" data-feature="allowAccentChange">
<span>Акцент</span>
<button
class="accent-swatch is-active"
@ -194,7 +221,7 @@
</div>
<!-- Настройки пользовательского linear-gradient акцента. -->
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<div class="accent-custom-panel" data-accent-custom-panel data-feature="allowAccentChange" hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
@ -222,6 +249,7 @@
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</section>
</div>
<!-- Каталог карточек генерируется из ecosystemSections в script.js. -->

323
script.js
View File

@ -64,34 +64,54 @@ const defaultPreferences = {
customAccentFrom: "#8b5cf6",
customAccentTo: "#22d3ee",
customAccentAngle: 135,
navLayout: "sidebar",
showNumbers: false,
showTreeTags: true,
treeTwoColumn: false,
autoCollapseSearch: true,
sidebarWidth: 390,
searchPlaceholder: "Grafana, S3, журнал, сервер...",
};
const defaultPanelFeatures = {
allowNavLayoutChange: true,
allowSearch: true,
allowViewChange: true,
allowShowNumbers: true,
allowTreeOptions: true,
allowSidebarResize: true,
allowThemeChange: true,
allowAccentChange: true,
};
const availableNavLayouts = ["sidebar", "top"];
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const availableAccents = ["violet", "cyan", "lime", "amber", "custom"];
// ============================================================================
// Runtime state and DOM references
// ============================================================================
// preferences - текущие настройки интерфейса, объединенные с localStorage.
const preferences = { ...defaultPreferences, ...readPreferences() };
// preferences - текущие настройки интерфейса; итоговые значения собираются в bootstrap().
let panelDefaults = { ...defaultPreferences };
let panelFeatures = { ...defaultPanelFeatures };
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 searchField = document.querySelector("[data-search-field]");
const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]");
const settingsOverlay = document.querySelector("[data-settings-overlay]");
const settingsClose = document.querySelector("[data-settings-close]");
const themeButtons = document.querySelectorAll("[data-theme]");
const navLayoutButtons = document.querySelectorAll("[data-nav-layout]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const accentCustomPanel = document.querySelector("[data-accent-custom-panel]");
@ -104,6 +124,7 @@ const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const treeTwoColumnInput = document.querySelector("[data-tree-two-column]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const 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");
@ -115,7 +136,11 @@ bootstrap();
async function bootstrap() {
try {
const panelData = await loadPanelConfig();
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;
@ -192,15 +217,40 @@ document.addEventListener("click", (event) => {
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 (preferences.navLayout === "sidebar") {
renderNavigationTree();
}
savePreferences(preferences);
});
});
// Переключение режима отображения каталога.
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowViewChange) {
return;
}
preferences.view = button.dataset.view;
applyPreferences(preferences);
savePreferences(preferences);
@ -210,6 +260,10 @@ viewButtons.forEach((button) => {
// Выбор цветового акцента из пресетов или пользовательского градиента.
accentButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.accent = button.dataset.accent;
applyPreferences(preferences);
savePreferences(preferences);
@ -218,6 +272,10 @@ accentButtons.forEach((button) => {
// Изменение параметров своего градиента сразу переключает accent на custom.
function handleCustomAccentChange() {
if (!panelFeatures.allowAccentChange) {
return;
}
preferences.customAccentFrom = customAccentFromInput.value;
preferences.customAccentTo = customAccentToInput.value;
preferences.customAccentAngle = Number(customAccentAngleInput.value);
@ -232,6 +290,10 @@ customAccentAngleInput.addEventListener("input", handleCustomAccentChange);
// Показывает/скрывает номера в секциях, группах и карточках.
showNumbersInput.addEventListener("change", () => {
if (!panelFeatures.allowShowNumbers) {
return;
}
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
@ -240,6 +302,10 @@ showNumbersInput.addEventListener("change", () => {
// Показывает/скрывает поисковые теги в файловом дереве.
// Взаимоисключимо с двухколоночным режимом элементов папок.
showTreeTagsInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || preferences.navLayout === "top") {
return;
}
preferences.showTreeTags = showTreeTagsInput.checked;
if (preferences.showTreeTags) {
preferences.treeTwoColumn = false;
@ -251,6 +317,10 @@ showTreeTagsInput.addEventListener("change", () => {
// Компактный двухколоночный режим элементов внутри папок дерева.
// Взаимоисключимо с отображением тегов.
treeTwoColumnInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || preferences.navLayout === "top") {
return;
}
preferences.treeTwoColumn = treeTwoColumnInput.checked;
if (preferences.treeTwoColumn) {
preferences.showTreeTags = false;
@ -264,6 +334,10 @@ treeTwoColumnInput.addEventListener("change", () => {
// Включает/выключает авто-раскрытие веток дерева при поиске.
autoCollapseSearchInput.addEventListener("change", () => {
if (!panelFeatures.allowTreeOptions || preferences.navLayout === "top") {
return;
}
preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
applyPreferences(preferences);
renderNavigationTree();
@ -273,6 +347,10 @@ autoCollapseSearchInput.addEventListener("change", () => {
// Drag-resize бокового меню.
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
sidebarResizer.addEventListener("pointerdown", (event) => {
if (preferences.navLayout === "top" || !panelFeatures.allowSidebarResize) {
return;
}
event.preventDefault();
document.body.classList.add("is-resizing-sidebar");
sidebarResizer.setPointerCapture(event.pointerId);
@ -301,8 +379,7 @@ sidebarResizer.addEventListener("pointerdown", (event) => {
// ============================================================================
// Panel config loading
// ============================================================================
async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
const settings = await fetchJson(settingsPath);
async function loadPanelDataFromSettings(settings) {
const dataUrl = typeof settings.dataUrl === "string" ? settings.dataUrl.trim() : "";
let panelSource = settings.data;
@ -317,6 +394,117 @@ async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
return normalizePanelData(panelSource);
}
function applyOrganizationConfig(settings) {
panelDefaults = normalizePreferences({
...defaultPreferences,
...(settings?.defaults && typeof settings.defaults === "object" ? settings.defaults : {}),
});
panelFeatures = {
...defaultPanelFeatures,
...(settings?.features && typeof settings.features === "object" ? settings.features : {}),
};
const placeholder =
typeof settings?.defaults?.searchPlaceholder === "string"
? settings.defaults.searchPlaceholder.trim()
: panelDefaults.searchPlaceholder;
if (searchInput && placeholder) {
searchInput.placeholder = placeholder;
}
}
function normalizePreferences(raw) {
const next = { ...raw };
if (!availableViews.includes(next.view)) {
next.view = defaultPreferences.view;
}
if (!availableAccents.includes(next.accent)) {
next.accent = defaultPreferences.accent;
}
if (!availableNavLayouts.includes(next.navLayout)) {
next.navLayout = defaultPreferences.navLayout;
}
next.customAccentFrom =
normalizeHexColor(next.customAccentFrom) || defaultPreferences.customAccentFrom;
next.customAccentTo = normalizeHexColor(next.customAccentTo) || defaultPreferences.customAccentTo;
next.customAccentAngle = clamp(
Number(next.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
next.showNumbers = Boolean(next.showNumbers);
next.showTreeTags = Boolean(next.showTreeTags);
next.treeTwoColumn = Boolean(next.treeTwoColumn);
next.autoCollapseSearch = Boolean(next.autoCollapseSearch);
next.sidebarWidth = clamp(
Number(next.sidebarWidth) || defaultPreferences.sidebarWidth,
260,
getSidebarMaxWidth(next),
);
if (next.treeTwoColumn) {
next.showTreeTags = false;
} else if (next.showTreeTags) {
next.treeTwoColumn = false;
}
if (next.navLayout === "top") {
next.treeTwoColumn = false;
next.showTreeTags = false;
}
return next;
}
function enforceFeatureLocks(rawPreferences) {
const next = normalizePreferences(rawPreferences);
if (!panelFeatures.allowNavLayoutChange) {
next.navLayout = panelDefaults.navLayout;
}
if (!panelFeatures.allowThemeChange) {
next.theme = panelDefaults.theme;
}
if (!panelFeatures.allowViewChange) {
next.view = panelDefaults.view;
}
if (!panelFeatures.allowAccentChange) {
next.accent = panelDefaults.accent;
next.customAccentFrom = panelDefaults.customAccentFrom;
next.customAccentTo = panelDefaults.customAccentTo;
next.customAccentAngle = panelDefaults.customAccentAngle;
}
if (!panelFeatures.allowShowNumbers) {
next.showNumbers = panelDefaults.showNumbers;
}
if (!panelFeatures.allowTreeOptions || next.navLayout === "top") {
next.showTreeTags = false;
next.treeTwoColumn = false;
next.autoCollapseSearch = panelDefaults.autoCollapseSearch;
}
if (!panelFeatures.allowSidebarResize || next.navLayout === "top") {
next.sidebarWidth = panelDefaults.sidebarWidth;
}
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);
@ -691,6 +879,11 @@ function matchesText(values, query) {
// Строит левое файловое дерево из той же структуры данных, что и каталог.
// Это важно: дерево и карточки всегда остаются синхронизированными.
function renderNavigationTree() {
if (preferences.navLayout === "top") {
navTree.replaceChildren();
return;
}
const query = normalize(searchInput.value);
const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch);
const fragment = document.createDocumentFragment();
@ -877,65 +1070,95 @@ function attachCardInteractions() {
// - класс режима отображения каталога;
// - состояния toggle-контролов в настройках.
function applyPreferences(nextPreferences) {
if (!availableViews.includes(nextPreferences.view)) {
nextPreferences.view = defaultPreferences.view;
}
const lockedPreferences = enforceFeatureLocks(nextPreferences);
Object.assign(preferences, lockedPreferences);
const activePreferences = preferences;
if (!availableAccents.includes(nextPreferences.accent)) {
nextPreferences.accent = defaultPreferences.accent;
}
nextPreferences.customAccentFrom = normalizeHexColor(nextPreferences.customAccentFrom) || defaultPreferences.customAccentFrom;
nextPreferences.customAccentTo = normalizeHexColor(nextPreferences.customAccentTo) || defaultPreferences.customAccentTo;
nextPreferences.customAccentAngle = clamp(
Number(nextPreferences.customAccentAngle) || defaultPreferences.customAccentAngle,
0,
360,
);
if (nextPreferences.treeTwoColumn) {
nextPreferences.showTreeTags = false;
} else if (nextPreferences.showTreeTags) {
nextPreferences.treeTwoColumn = false;
}
document.documentElement.dataset.theme = nextPreferences.theme;
document.documentElement.dataset.accent = nextPreferences.accent;
applyAccentVariables(nextPreferences);
document.documentElement.dataset.theme = activePreferences.theme;
document.documentElement.dataset.accent = activePreferences.accent;
document.documentElement.dataset.navLayout =
activePreferences.navLayout === "top" ? "top" : "sidebar";
applyAccentVariables(activePreferences);
document.documentElement.style.setProperty(
"--sidebar-width",
`${clamp(
Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
Number(activePreferences.sidebarWidth) || defaultPreferences.sidebarWidth,
260,
getSidebarMaxWidth(nextPreferences),
getSidebarMaxWidth(activePreferences),
)}px`,
);
document.documentElement.style.setProperty(
"--shell-max-width",
nextPreferences.treeTwoColumn ? "1920px" : "1560px",
activePreferences.treeTwoColumn ? "1920px" : "1560px",
);
document.documentElement.style.setProperty(
"--sidebar-max-width",
`${getSidebarMaxWidth(nextPreferences)}px`,
`${getSidebarMaxWidth(activePreferences)}px`,
);
catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
catalog.classList.add(`${nextPreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers));
document.documentElement.dataset.showTreeTags = String(Boolean(nextPreferences.showTreeTags));
document.documentElement.dataset.treeTwoColumn = String(Boolean(nextPreferences.treeTwoColumn));
showNumbersInput.checked = Boolean(nextPreferences.showNumbers);
showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags);
treeTwoColumnInput.checked = Boolean(nextPreferences.treeTwoColumn);
autoCollapseSearchInput.checked = Boolean(nextPreferences.autoCollapseSearch);
customAccentFromInput.value = nextPreferences.customAccentFrom;
customAccentToInput.value = nextPreferences.customAccentTo;
customAccentAngleInput.value = String(nextPreferences.customAccentAngle);
customAccentAngleValue.textContent = `${nextPreferences.customAccentAngle}°`;
accentCustomPanel.hidden = nextPreferences.accent !== "custom";
updateCustomAccentPreview(nextPreferences);
setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent);
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.treeTwoColumn = String(Boolean(activePreferences.treeTwoColumn));
showNumbersInput.checked = Boolean(activePreferences.showNumbers);
showTreeTagsInput.checked = Boolean(activePreferences.showTreeTags);
treeTwoColumnInput.checked = Boolean(activePreferences.treeTwoColumn);
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);
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 = activePreferences.navLayout === "top";
}
if (sidebarResizer) {
sidebarResizer.hidden =
activePreferences.navLayout === "top" || !panelFeatures.allowSidebarResize;
}
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;
});
}
function isSettingsSectionVisible(sectionName, activePreferences) {
switch (sectionName) {
case "layout":
return panelFeatures.allowNavLayoutChange;
case "catalog":
return panelFeatures.allowViewChange || panelFeatures.allowShowNumbers;
case "tree":
return panelFeatures.allowTreeOptions && activePreferences.navLayout !== "top";
case "appearance":
return panelFeatures.allowThemeChange || panelFeatures.allowAccentChange;
default:
return true;
}
}
// Применяет CSS-переменные акцента для пресетов или пользовательского градиента.

View File

@ -0,0 +1,26 @@
{
"dataUrl": "./panel-data.json",
"data": null,
"defaults": {
"navLayout": "top",
"theme": "dark",
"view": "cards",
"accent": "cyan",
"showNumbers": false,
"showTreeTags": false,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Поиск сервиса..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": false,
"allowTreeOptions": false,
"allowSidebarResize": false,
"allowThemeChange": true,
"allowAccentChange": true
}
}

View File

@ -1,4 +1,29 @@
{
"dataUrl": "./panel-data.json",
"data": null
"data": null,
"defaults": {
"navLayout": "sidebar",
"theme": "dark",
"view": "cards",
"accent": "violet",
"customAccentFrom": "#8b5cf6",
"customAccentTo": "#22d3ee",
"customAccentAngle": 135,
"showNumbers": false,
"showTreeTags": true,
"treeTwoColumn": false,
"autoCollapseSearch": true,
"sidebarWidth": 390,
"searchPlaceholder": "Grafana, S3, журнал, сервер..."
},
"features": {
"allowNavLayoutChange": true,
"allowSearch": true,
"allowViewChange": true,
"allowShowNumbers": true,
"allowTreeOptions": true,
"allowSidebarResize": true,
"allowThemeChange": true,
"allowAccentChange": true
}
}

View File

@ -143,6 +143,48 @@ a {
align-items: start;
}
/* Компактная шапка: название + поиск, без дерева и resizer. */
html[data-nav-layout="top"] .app-layout,
.app-layout[data-nav-layout="top"] {
grid-template-columns: 1fr;
}
html[data-nav-layout="top"] .sidebar-resizer {
display: none;
}
html[data-nav-layout="top"] .side-panel {
position: static;
}
html[data-nav-layout="top"] .side-panel .hero-content {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 12px 16px;
max-height: none;
min-height: auto;
overflow: visible;
padding: 14px 58px 14px 18px;
}
html[data-nav-layout="top"] .side-panel .hero-content > .eyebrow {
margin: 0;
flex: 0 1 auto;
max-width: min(100%, 360px);
}
html[data-nav-layout="top"] .search-field {
margin-top: 0;
flex: 1 1 220px;
max-width: min(100%, 560px);
}
html[data-nav-layout="top"] .nav-tree {
display: none;
}
/* ==========================================================================
04. Sidebar Resizer
--------------------------------------------------------------------------
@ -410,6 +452,31 @@ html[data-tree-two-column="true"] .tree-node.tree-file small {
line-height: 1;
}
.settings-section {
display: grid;
gap: 10px;
}
.settings-section[hidden] {
display: none;
}
.settings-section-title {
margin: 0;
color: var(--text);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.settings-section-hint {
margin: 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.45;
}
/* ==========================================================================
07. Search Field And Control Inputs
--------------------------------------------------------------------------