From 284a7f6b7548b0a459881593e834f4b919035cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=91=D1=80=D0=B0=D1=82=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85?= Date: Sun, 28 Jun 2026 16:28:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D0=B3=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B0=D1=8F=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D1=81=20=D0=BF=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 222 +++- examples/.vscode/settings.json | 3 + examples/README.md | 73 ++ examples/flat/index.html | 176 +++ examples/flat/panel-data.json | 75 ++ examples/flat/settings.json | 4 + examples/full/index.html | 176 +++ examples/full/panel-data.json | 133 ++ examples/full/settings.json | 4 + examples/index.html | 137 +++ examples/mixed/index.html | 176 +++ examples/mixed/panel-data.json | 95 ++ examples/mixed/settings.json | 4 + examples/section-items/index.html | 176 +++ examples/section-items/panel-data.json | 101 ++ examples/section-items/settings.json | 4 + examples/shared/assets/ceph.svg | 1 + examples/shared/assets/config.svg | 1 + examples/shared/assets/database.svg | 1 + examples/shared/assets/gitea.svg | 1 + examples/shared/assets/grafana.svg | 1 + examples/shared/assets/kubernetes.svg | 1 + examples/shared/assets/ldap.svg | 1 + examples/shared/assets/messages.svg | 1 + examples/shared/assets/opensearch.svg | 1 + examples/shared/assets/prometheus.svg | 1 + examples/shared/assets/rabbitmq.svg | 1 + examples/shared/assets/redmine.svg | 1 + examples/shared/assets/rules.svg | 1 + examples/shared/assets/search.svg | 1 + examples/shared/assets/server.svg | 1 + examples/shared/assets/xmpp.svg | 1 + examples/shared/assets/xpath.svg | 1 + examples/shared/script.js | 1139 +++++++++++++++++ examples/shared/styles.css | 1542 ++++++++++++++++++++++++ index.html | 2 +- panel-data-flat.example.json | 19 + panel-data-full.example.json | 29 + panel-data-mixed.example.json | 33 + panel-data-section-items.example.json | 26 + script.js | 176 ++- styles.css | 17 + 42 files changed, 4463 insertions(+), 96 deletions(-) create mode 100644 examples/.vscode/settings.json create mode 100644 examples/README.md create mode 100644 examples/flat/index.html create mode 100644 examples/flat/panel-data.json create mode 100644 examples/flat/settings.json create mode 100644 examples/full/index.html create mode 100644 examples/full/panel-data.json create mode 100644 examples/full/settings.json create mode 100644 examples/index.html create mode 100644 examples/mixed/index.html create mode 100644 examples/mixed/panel-data.json create mode 100644 examples/mixed/settings.json create mode 100644 examples/section-items/index.html create mode 100644 examples/section-items/panel-data.json create mode 100644 examples/section-items/settings.json create mode 100644 examples/shared/assets/ceph.svg create mode 100644 examples/shared/assets/config.svg create mode 100644 examples/shared/assets/database.svg create mode 100644 examples/shared/assets/gitea.svg create mode 100644 examples/shared/assets/grafana.svg create mode 100644 examples/shared/assets/kubernetes.svg create mode 100644 examples/shared/assets/ldap.svg create mode 100644 examples/shared/assets/messages.svg create mode 100644 examples/shared/assets/opensearch.svg create mode 100644 examples/shared/assets/prometheus.svg create mode 100644 examples/shared/assets/rabbitmq.svg create mode 100644 examples/shared/assets/redmine.svg create mode 100644 examples/shared/assets/rules.svg create mode 100644 examples/shared/assets/search.svg create mode 100644 examples/shared/assets/server.svg create mode 100644 examples/shared/assets/xmpp.svg create mode 100644 examples/shared/assets/xpath.svg create mode 100644 examples/shared/script.js create mode 100644 examples/shared/styles.css create mode 100644 panel-data-flat.example.json create mode 100644 panel-data-full.example.json create mode 100644 panel-data-mixed.example.json create mode 100644 panel-data-section-items.example.json diff --git a/README.md b/README.md index e6b8f2a..945d38f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,30 @@ # Панель экосистемы -Статическая навигационная панель для быстрого доступа к сервисам инфраструктуры. Поддерживает иерархию **раздел → подраздел → элемент**, поиск, дерево навигации, несколько режимов отображения и настройку внешнего вида. +Статическая навигационная панель для быстрого доступа к сервисам инфраструктуры. Поддерживает гибкую иерархию данных, поиск, дерево навигации, несколько режимов отображения и настройку внешнего вида. Стек: HTML, CSS, vanilla JavaScript. Без сборщиков и фреймворков. +## Оглавление + +- [Возможности](#возможности) +- [Быстрый старт](#быстрый-старт) +- [Структура проекта](#структура-проекта) +- [Конфигурация](#конфигурация) + - [`settings.json`](#settingsjson) + - [Форматы иерархии](#форматы-иерархии) + - [Схема `panel-data`](#схема-panel-data) + - [Поля данных](#поля-данных) + - [Примеры данных](#примеры-данных) + - [Готовые демо-страницы (`examples/`)](#готовые-демо-страницы-examples) + - [Типы сервисов (`serviceMeta`)](#типы-сервисов-servicemeta) + - [Рекомендуемые типы сервисов](#рекомендуемые-типы-сервисов) +- [Настройки интерфейса (⚙)](#настройки-интерфейса-) +- [Поведение интерфейса](#поведение-интерфейса) +- [localStorage](#localstorage) +- [Разработка](#разработка) +- [Ограничения](#ограничения) +- [Лицензия](#лицензия) + ## Возможности - **Древовидная навигация** слева в стиле файлового менеджера @@ -15,6 +36,7 @@ - **Изменяемая ширина** бокового меню (drag) - **Сохранение состояния** в `localStorage` (настройки UI, раскрытие веток) - **Конфигурация через JSON** — локальный объект или загрузка по URL +- **Гибкая иерархия**: полная вложенность, раздел без подраздела, плоский список ## Быстрый старт @@ -31,12 +53,20 @@ npx serve . ``` ecosystem/ -├── index.html # Разметка страницы -├── styles.css # Стили, темы, адаптив -├── script.js # Логика, рендер, настройки -├── settings.json # Источник данных панели (URL или inline) -├── panel-data.json # Данные навигации (по умолчанию) -├── assets/ # SVG-иконки сервисов +├── index.html # Разметка страницы +├── styles.css # Стили, темы, адаптив +├── script.js # Логика, рендер, примеры структур +├── settings.json # Источник данных (URL или inline) +├── panel-data.json # Данные навигации (рабочий файл) +├── examples/ # Готовые демо-страницы (см. examples/README.md) +│ ├── index.html # Оглавление примеров +│ ├── shared/ # styles, script, assets для демо +│ ├── full/ # section → group → item +│ ├── section-items/ # section → item +│ ├── flat/ # плоский items[] +│ └── mixed/ # items + groups +├── panel-data-full.example.json # JSON-сниппет (без страницы) +├── assets/ # SVG-иконки сервисов └── README.md ``` @@ -54,11 +84,11 @@ ecosystem/ } ``` -Можно указать внешний адрес: +Можно указать внешний адрес или любой из example-файлов: ```json { - "dataUrl": "https://example.com/ecosystem/panel-data.json" + "dataUrl": "./panel-data-flat.example.json" } ``` @@ -68,7 +98,7 @@ ecosystem/ { "dataUrl": "", "data": { - "title": "Панель экосистемы", + "title": "Заголовок панели", "sections": [] } } @@ -78,76 +108,153 @@ ecosystem/ При ошибке загрузки или невалидном JSON на странице показывается экран с описанием проблемы. -### `panel-data.json` +### Форматы иерархии -Основной файл с содержимым панели. +Панель поддерживает три базовых формата. Старый **section → group → item** работает без изменений. + +| Формат | Структура | Дерево | Каталог | +|--------|-----------|--------|---------| +| **Полная** | `sections[].groups[].items[]` | раздел → подраздел → файлы | collapsible разделы и подразделы | +| **Раздел → item** | `sections[].items[]` | раздел → файлы | карточки внутри раздела | +| **Плоский список** | корневой `items[]` | плоский список файлов | только сетка карточек | +| **Смешанный** | `sections[].items[]` + `groups[]` | прямые файлы, затем подразделы | прямые карточки, затем подразделы | + +Минимум данных: корень должен содержать `sections` **или** `items`. Каждый раздел — `groups` **или** `items`. Каждый элемент — `title` и `type`. + +### Схема `panel-data` + +Абстрактная структура файла данных (без привязки к конкретным сервисам): ```json { - "title": "Панель экосистемы", + "title": "<заголовок панели>", "sections": [ { - "id": "user", - "number": "1", - "title": "Интерфейс пользователя", - "description": "Описание раздела", + "id": "", + "number": "<опционально>", + "title": "<название раздела>", + "description": "<опционально>", + "items": [ + { + "id": "<стабильный-ключ>", + "number": "<опционально>", + "title": "<название>", + "type": "<ключ из serviceMeta>", + "tags": ["<тег>"], + "url": "<ссылка>" + } + ], "groups": [ { - "id": "message-filtering", - "number": "1.1", - "title": "Управление фильтрацией", - "description": "Описание подраздела", - "items": [ - { - "number": "1.1.1", - "title": "Редактирование правил", - "type": "rules", - "tags": ["правила", "фильтрация"], - "url": "https://rules.example.com" - } - ] + "id": "", + "number": "<опционально>", + "title": "<название подраздела>", + "description": "<опционально>", + "items": ["<элементы — та же форма>"] } ] } ], - "serviceMeta": {} + "items": ["<альтернатива sections: плоский список>"], + "serviceMeta": { + "": { + "label": "<подпись типа>", + "icon": "assets/<иконка>.svg" + } + } } ``` +Используй **либо** `sections`, **либо** корневой `items`. Внутри раздела — **либо** `groups`, **либо** `items`, **либо** оба. + ### Поля данных | Уровень | Поля | Описание | |---------|------|----------| | **Корень** | `title` | Заголовок панели (sidebar + ``) | -| | `sections` | Массив разделов | -| | `serviceMeta` | Опционально: доп. типы сервисов | -| **section** | `id`, `number`, `title`, `description`, `groups` | Крупный раздел | -| **group** | `id`, `number`, `title`, `description`, `items` | Подраздел | -| **item** | `number`, `title`, `type`, `tags`, `url` | Кликабельный элемент | +| | `sections` | Массив разделов (если нет корневого `items`) | +| | `items` | Плоский список без разделов | +| | `serviceMeta` | Дополнительные или переопределённые типы сервисов | +| **section** | `id`, `title` | Обязательные идентификатор и название | +| | `number`, `description` | Опционально | +| | `groups`, `items` | Хотя бы одно из двух | +| **group** | `id`, `title`, `items` | Подраздел с элементами | +| | `number`, `description` | Опционально | +| **item** | `title`, `type` | Обязательные название и тип | +| | `url` | Ссылка; `"#"` — заглушка | +| | `tags` | Массив строк для поиска | +| | `id` | Стабильный ключ (DOM, фокус из дерева) | +| | `number` | Отображаемый номер | -- `type` — ключ из `serviceMeta` (определяет иконку и подпись типа) -- `tags` — используются в поиске и опционально в дереве -- `url` — ссылка перехода; `"#"` означает заглушку (клик не ведёт никуда) +- `type` — ключ из встроенного `defaultServiceMeta` или из `serviceMeta` в JSON +- `id` элемента нужен, если нет `number` — иначе ключ генерируется автоматически + +### Примеры данных + +Готовые демо-страницы — в папке [`examples/`](examples/README.md). Корневые `panel-data-*.example.json` — те же структуры в виде JSON-сниппетов. + +### Готовые демо-страницы (`examples/`) + +Каждый пример — отдельная страница с `index.html`, `settings.json` и `panel-data.json`. Общие ресурсы в `examples/shared/`. + +```bash +cd examples +npx serve . +``` + +| Путь | Формат | +|------|--------| +| `/` | оглавление примеров | +| `/full/` | section → group → item | +| `/section-items/` | section → item | +| `/flat/` | плоский `items[]` | +| `/mixed/` | items + groups в разделе | + +Подробнее: [examples/README.md](examples/README.md). ### Типы сервисов (`serviceMeta`) -Базовые типы заданы в `script.js`: +Базовые типы заданы в `script.js` (`defaultServiceMeta`): `ceph`, `cloudbeaver`, `config`, `gitea`, `grafana`, `ipmi`, `kubernetes`, `ldap`, `messages`, `opensearch`, `postgresql`, `prometheus`, `rabbitmq`, `redmine`, `rules`, `xpath`, `xmpp` -В `panel-data.json` можно добавить или переопределить типы: +Переопределение или добавление типов в JSON: ```json { "serviceMeta": { - "my-service": { - "label": "My Service", + "my-type": { + "label": "Подпись", "icon": "assets/config.svg" } } } ``` +### Рекомендуемые типы сервисов + +Популярные `type`, которые можно добавить в `serviceMeta` (иконки — переиспользовать из `assets/`): + +| Категория | Типы (`type`) | Комментарий | +|-----------|---------------|-------------| +| **Прокси / балансировка** | `nginx`, `traefik` | reverse proxy, ingress | +| **Контейнеры** | `docker`, `portainer`, `argocd` | runtime, UI, GitOps | +| **CI/CD** | `jenkins`, `gitlab`, `harbor` | сборка, репозиторий, registry | +| **Базы данных** | `mysql`, `mongodb`, `redis` | рядом с уже имеющимися `postgresql`, `opensearch` | +| **Очереди / стриминг** | `kafka` | аналог `rabbitmq` | +| **Мониторинг / логи** | `loki`, `zabbix`, `sentry` | логи, метрики, ошибки | +| **Безопасность / IAM** | `vault`, `keycloak`, `consul` | секреты, SSO, service mesh | +| **Поиск** | `elasticsearch`, `sonarqube` | полнотекст, качество кода | +| **Хранилище** | `minio` | S3-совместимое | +| **IaC** | `terraform` | инфраструктура как код | +| **Трекеры** | `jira` | задачи, рядом с `redmine` | + +Чтобы подключить тип: + +1. Добавь запись в `serviceMeta` вашего JSON **или** в `defaultServiceMeta` в `script.js` +2. При необходимости положи отдельную SVG в `assets/` +3. Укажи `"type": "nginx"` (и т.д.) в элементах + ## Настройки интерфейса (⚙) Доступны в popup-окне и сохраняются в `localStorage` (`ecosystem-panel-preferences`): @@ -209,40 +316,27 @@ ecosystem/ ## Разработка -### Добавить новый элемент - -1. Открой `panel-data.json` -2. Найди нужный `group` в `sections` -3. Добавь объект в `items`: - -```json -{ - "number": "2.5.3", - "title": "Новый сервис", - "type": "gitea", - "tags": ["gitea", "новый"], - "url": "https://gitea.example.com" -} -``` +### Добавить элемент +1. Открой `panel-data.json` (или свой JSON из `dataUrl`) +2. Найди нужный раздел / подраздел **или** добавь в `items` / корневой `items` +3. Добавь объект с полями `title`, `type`, при необходимости `tags`, `url`, `id`, `number` 4. Обнови страницу -### Добавить новую иконку +### Добавить иконку и тип -1. Положи SVG в `assets/` -2. Зарегистрируй тип в `serviceMeta` внутри `panel-data.json` или используй существующий `type` +1. Положи SVG в `assets/` (или переиспользуй существующую) +2. Зарегистрируй тип в `serviceMeta` JSON или в `defaultServiceMeta` в `script.js` ### Внешний источник данных -Укажи URL в `settings.json`: - ```json { "dataUrl": "https://your-server/panel-data.json" } ``` -Убедись, что сервер отдаёт корректный `Content-Type: application/json` и разрешает CORS, если домен отличается от панели. +Убедись, что сервер отдаёт `Content-Type: application/json` и разрешает CORS при другом домене. ## Ограничения diff --git a/examples/.vscode/settings.json b/examples/.vscode/settings.json new file mode 100644 index 0000000..63662ed --- /dev/null +++ b/examples/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5502 +} \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1610df3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,73 @@ +# Примеры панели экосистемы + +Самодостаточные демо-страницы для всех поддерживаемых форматов данных. Каждый пример можно хостить отдельно — в папке есть всё необходимое для работы через HTTP-сервер. + +## Структура + +``` +examples/ +├── index.html # Оглавление примеров +├── shared/ # Общие ресурсы для всех демо +│ ├── styles.css +│ ├── script.js +│ └── assets/ # SVG-иконки +├── full/ # section → group → item +│ ├── index.html +│ ├── settings.json +│ └── panel-data.json +├── section-items/ # section → item +├── flat/ # корневой items[] +└── mixed/ # items + groups в одном разделе +``` + +## Запуск + +Из папки `examples`: + +```bash +cd examples +npx serve . +``` + +Открой: + +- `http://localhost:3000/` — оглавление +- `http://localhost:3000/full/` — полная иерархия +- `http://localhost:3000/section-items/` — раздел без подразделов +- `http://localhost:3000/flat/` — плоский список +- `http://localhost:3000/mixed/` — смешанный раздел + +Альтернатива — сервер из корня репозитория: + +```bash +cd ecosystem +npx serve . +``` + +Тогда примеры доступны по пути `/examples/...`. + +## Что внутри каждого примера + +| Файл | Назначение | +|------|------------| +| `index.html` | Страница панели, подключает `../shared/` | +| `settings.json` | Указывает `./panel-data.json` | +| `panel-data.json` | Демо-данные выбранного формата | + +## Обновление shared/ + +Папка `shared/` — копия `styles.css`, `script.js` и `assets/` из корня проекта. После изменений в основной панели синхронизируй: + +```powershell +Copy-Item ..\styles.css, ..\script.js shared\ +Copy-Item ..\assets\* shared\assets\ -Recurse -Force +``` + +## Копирование как шаблон + +Чтобы сделать свою панель на базе примера: + +1. Скопируй папку нужного формата (например, `flat/`) +2. Отредактируй `panel-data.json` +3. При необходимости добавь типы в `serviceMeta` +4. Хости папку примера вместе с `shared/` (относительные пути уже настроены) diff --git a/examples/flat/index.html b/examples/flat/index.html new file mode 100644 index 0000000..3554015 --- /dev/null +++ b/examples/flat/index.html @@ -0,0 +1,176 @@ +<!doctype html> +<html lang="ru"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta + name="description" + content="Демо панели: плоский список элементов без разделов." + /> + <title>Пример: плоский список + + + + + + +
+
+ +
+
+ + + + +
+ +
+
+
+

View settings

+

Настройки

+
+ +
+ +
+ Тема + + +
+ +
+ Вид + + + + +
+ + + + + + + + + +
+ Акцент + + + + + +
+ + +
+ +
+ + + + +
+
+ + + + diff --git a/examples/flat/panel-data.json b/examples/flat/panel-data.json new file mode 100644 index 0000000..157144d --- /dev/null +++ b/examples/flat/panel-data.json @@ -0,0 +1,75 @@ +{ + "title": "Пример: плоский список", + "items": [ + { + "id": "grafana", + "title": "Grafana", + "type": "grafana", + "tags": ["grafana", "метрики", "дашборд"], + "url": "https://grafana.example.com" + }, + { + "id": "prometheus", + "title": "Prometheus", + "type": "prometheus", + "tags": ["prometheus", "метрики"], + "url": "https://prometheus.example.com" + }, + { + "id": "opensearch", + "title": "OpenSearch", + "type": "opensearch", + "tags": ["логи", "opensearch"], + "url": "https://opensearch.example.com" + }, + { + "id": "kubernetes", + "title": "Kubernetes", + "type": "kubernetes", + "tags": ["k8s", "кластер"], + "url": "https://k8s.example.com" + }, + { + "id": "gitea", + "title": "Gitea", + "type": "gitea", + "tags": ["git", "repo"], + "url": "https://gitea.example.com" + }, + { + "id": "postgresql", + "title": "PostgreSQL", + "type": "postgresql", + "tags": ["база", "sql"], + "url": "https://postgres.example.com" + }, + { + "id": "rabbitmq", + "title": "RabbitMQ", + "type": "rabbitmq", + "tags": ["очереди", "amqp"], + "url": "https://rabbitmq.example.com" + }, + { + "id": "ceph", + "title": "Ceph S3", + "type": "ceph", + "tags": ["s3", "хранилище"], + "url": "https://s3.example.com" + }, + { + "id": "ldap", + "title": "LDAP", + "type": "ldap", + "tags": ["ldap", "iam"], + "url": "https://ldap.example.com" + }, + { + "id": "redmine", + "title": "Redmine", + "type": "redmine", + "tags": ["задачи", "трекер"], + "url": "https://redmine.example.com" + } + ] +} diff --git a/examples/flat/settings.json b/examples/flat/settings.json new file mode 100644 index 0000000..342d127 --- /dev/null +++ b/examples/flat/settings.json @@ -0,0 +1,4 @@ +{ + "dataUrl": "./panel-data.json", + "data": null +} diff --git a/examples/full/index.html b/examples/full/index.html new file mode 100644 index 0000000..a9e3a62 --- /dev/null +++ b/examples/full/index.html @@ -0,0 +1,176 @@ + + + + + + + Пример: section → group → item + + + + + + +
+
+ +
+
+ + + + +
+ +
+
+
+

View settings

+

Настройки

+
+ +
+ +
+ Тема + + +
+ +
+ Вид + + + + +
+ + + + + + + + + +
+ Акцент + + + + + +
+ + +
+ +
+ + + + +
+
+ + + + diff --git a/examples/full/panel-data.json b/examples/full/panel-data.json new file mode 100644 index 0000000..b81c307 --- /dev/null +++ b/examples/full/panel-data.json @@ -0,0 +1,133 @@ +{ + "title": "Пример: section → group → item", + "sections": [ + { + "id": "monitoring", + "number": "1", + "title": "Мониторинг", + "description": "Метрики, алерты и журналы событий.", + "groups": [ + { + "id": "metrics", + "number": "1.1", + "title": "Метрики и дашборды", + "description": "Визуализация и сбор показателей.", + "items": [ + { + "id": "grafana-main", + "number": "1.1.1", + "title": "Grafana — основной", + "type": "grafana", + "tags": ["grafana", "дашборд", "метрики"], + "url": "https://grafana.example.com" + }, + { + "id": "prometheus", + "number": "1.1.2", + "title": "Prometheus", + "type": "prometheus", + "tags": ["prometheus", "метрики", "tsdb"], + "url": "https://prometheus.example.com" + }, + { + "id": "alerts", + "number": "1.1.3", + "title": "Правила алертинга", + "type": "rules", + "tags": ["алерты", "правила"], + "url": "#" + } + ] + }, + { + "id": "logs", + "number": "1.2", + "title": "Логи и поиск", + "description": "Централизованный просмотр журналов.", + "items": [ + { + "id": "opensearch", + "number": "1.2.1", + "title": "OpenSearch", + "type": "opensearch", + "tags": ["логи", "поиск", "opensearch"], + "url": "https://opensearch.example.com" + }, + { + "id": "error-log", + "number": "1.2.2", + "title": "Журнал ошибок", + "type": "opensearch", + "tags": ["ошибки", "журнал"], + "url": "#" + } + ] + } + ] + }, + { + "id": "infrastructure", + "number": "2", + "title": "Инфраструктура", + "description": "Кластеры, хранилища и сервисы данных.", + "groups": [ + { + "id": "clusters", + "number": "2.1", + "title": "Кластеры и оркестрация", + "description": "Управление вычислительными ресурсами.", + "items": [ + { + "id": "k8s", + "number": "2.1.1", + "title": "Kubernetes Dashboard", + "type": "kubernetes", + "tags": ["kubernetes", "k8s", "кластер"], + "url": "https://k8s.example.com" + }, + { + "id": "rabbitmq", + "number": "2.1.2", + "title": "RabbitMQ", + "type": "rabbitmq", + "tags": ["очереди", "rabbitmq"], + "url": "https://rabbitmq.example.com" + } + ] + }, + { + "id": "storage", + "number": "2.2", + "title": "Хранилища и БД", + "description": "Объектное хранилище и реляционные базы.", + "items": [ + { + "id": "ceph", + "number": "2.2.1", + "title": "Ceph — S3", + "type": "ceph", + "tags": ["ceph", "s3", "объекты"], + "url": "https://s3.example.com" + }, + { + "id": "postgresql", + "number": "2.2.2", + "title": "PostgreSQL", + "type": "postgresql", + "tags": ["postgresql", "база", "sql"], + "url": "https://postgres.example.com" + }, + { + "id": "cloudbeaver", + "number": "2.2.3", + "title": "CloudBeaver", + "type": "cloudbeaver", + "tags": ["sql", "база", "редактор"], + "url": "#" + } + ] + } + ] + } + ] +} diff --git a/examples/full/settings.json b/examples/full/settings.json new file mode 100644 index 0000000..342d127 --- /dev/null +++ b/examples/full/settings.json @@ -0,0 +1,4 @@ +{ + "dataUrl": "./panel-data.json", + "data": null +} diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..e4471f8 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,137 @@ + + + + + + + Примеры панели экосистемы + + + + + + + +
+
+ +
+

Ecosystem panel

+

Примеры структур данных

+

+ Каждый пример — самодостаточная страница с локальными settings.json и + panel-data.json. Общие стили, скрипт и иконки лежат в shared/. +

+ + + +
+ Запуск: из папки examples выполни + npx serve . и открой / или конкретный пример, например + /full/. Можно также поднять сервер из корня репозитория — + тогда адрес будет /examples/full/. +
+
+ + diff --git a/examples/mixed/index.html b/examples/mixed/index.html new file mode 100644 index 0000000..8651d34 --- /dev/null +++ b/examples/mixed/index.html @@ -0,0 +1,176 @@ + + + + + + + Пример: смешанный раздел + + + + + + +
+
+ +
+
+ + + + +
+ +
+
+
+

View settings

+

Настройки

+
+ +
+ +
+ Тема + + +
+ +
+ Вид + + + + +
+ + + + + + + + + +
+ Акцент + + + + + +
+ + +
+ +
+ + + + +
+
+ + + + diff --git a/examples/mixed/panel-data.json b/examples/mixed/panel-data.json new file mode 100644 index 0000000..83a7c34 --- /dev/null +++ b/examples/mixed/panel-data.json @@ -0,0 +1,95 @@ +{ + "title": "Пример: смешанный раздел", + "sections": [ + { + "id": "platform", + "number": "1", + "title": "Платформа", + "description": "Закреплённые ссылки и тематические подразделы в одном разделе.", + "items": [ + { + "id": "status", + "number": "1.0", + "title": "Статус платформы", + "type": "grafana", + "tags": ["статус", "overview"], + "url": "https://status.example.com" + }, + { + "id": "docs", + "number": "1.0", + "title": "Документация", + "type": "config", + "tags": ["docs", "wiki"], + "url": "https://docs.example.com" + }, + { + "id": "support", + "number": "1.0", + "title": "Поддержка", + "type": "redmine", + "tags": ["support", "тикеты"], + "url": "https://support.example.com" + } + ], + "groups": [ + { + "id": "observability", + "number": "1.1", + "title": "Наблюдаемость", + "description": "Метрики, логи и трассировка.", + "items": [ + { + "id": "grafana", + "number": "1.1.1", + "title": "Grafana", + "type": "grafana", + "tags": ["grafana"], + "url": "https://grafana.example.com" + }, + { + "id": "prometheus", + "number": "1.1.2", + "title": "Prometheus", + "type": "prometheus", + "tags": ["prometheus"], + "url": "https://prometheus.example.com" + }, + { + "id": "logs", + "number": "1.1.3", + "title": "Журнал событий", + "type": "opensearch", + "tags": ["логи"], + "url": "#" + } + ] + }, + { + "id": "data", + "number": "1.2", + "title": "Данные и очереди", + "description": "Базы данных и брокеры сообщений.", + "items": [ + { + "id": "postgres", + "number": "1.2.1", + "title": "PostgreSQL", + "type": "postgresql", + "tags": ["postgresql", "sql"], + "url": "https://postgres.example.com" + }, + { + "id": "mq", + "number": "1.2.2", + "title": "RabbitMQ", + "type": "rabbitmq", + "tags": ["очереди"], + "url": "https://rabbitmq.example.com" + } + ] + } + ] + } + ] +} diff --git a/examples/mixed/settings.json b/examples/mixed/settings.json new file mode 100644 index 0000000..342d127 --- /dev/null +++ b/examples/mixed/settings.json @@ -0,0 +1,4 @@ +{ + "dataUrl": "./panel-data.json", + "data": null +} diff --git a/examples/section-items/index.html b/examples/section-items/index.html new file mode 100644 index 0000000..a79c4c0 --- /dev/null +++ b/examples/section-items/index.html @@ -0,0 +1,176 @@ + + + + + + + Пример: section → item + + + + + + +
+
+ +
+
+ + + + +
+ +
+
+
+

View settings

+

Настройки

+
+ +
+ +
+ Тема + + +
+ +
+ Вид + + + + +
+ + + + + + + + + +
+ Акцент + + + + + +
+ + +
+ +
+ + + + +
+
+ + + + diff --git a/examples/section-items/panel-data.json b/examples/section-items/panel-data.json new file mode 100644 index 0000000..e2a48e4 --- /dev/null +++ b/examples/section-items/panel-data.json @@ -0,0 +1,101 @@ +{ + "title": "Пример: section → item", + "sections": [ + { + "id": "quick", + "number": "1", + "title": "Быстрый доступ", + "description": "Часто используемые сервисы без промежуточных подразделов.", + "items": [ + { + "id": "mail", + "number": "1.1", + "title": "Почта", + "type": "messages", + "tags": ["почта", "mail"], + "url": "https://mail.example.com" + }, + { + "id": "chat", + "number": "1.2", + "title": "Корпоративный чат", + "type": "xmpp", + "tags": ["xmpp", "чат"], + "url": "https://chat.example.com" + }, + { + "id": "tasks", + "number": "1.3", + "title": "Задачи", + "type": "redmine", + "tags": ["redmine", "задачи"], + "url": "https://tasks.example.com" + } + ] + }, + { + "id": "dev", + "number": "2", + "title": "Разработка", + "description": "Репозитории, CI и конфигурации.", + "items": [ + { + "id": "gitea", + "number": "2.1", + "title": "Gitea", + "type": "gitea", + "tags": ["git", "repo", "gitea"], + "url": "https://gitea.example.com" + }, + { + "id": "config", + "number": "2.2", + "title": "Конфигурации", + "type": "config", + "tags": ["config", "настройки"], + "url": "#" + }, + { + "id": "xpath-debug", + "number": "2.3", + "title": "Отладка xPath", + "type": "xpath", + "tags": ["xpath", "отладка"], + "url": "#" + } + ] + }, + { + "id": "admin", + "number": "3", + "title": "Администрирование", + "description": "Управление пользователями и железом.", + "items": [ + { + "id": "ldap", + "number": "3.1", + "title": "LDAP Account Manager", + "type": "ldap", + "tags": ["ldap", "пользователи"], + "url": "https://ldap.example.com" + }, + { + "id": "server-01", + "number": "3.2", + "title": "Сервер 01 — IPMI", + "type": "ipmi", + "tags": ["ipmi", "сервер", "железо"], + "url": "#" + }, + { + "id": "server-02", + "number": "3.3", + "title": "Сервер 02 — IPMI", + "type": "ipmi", + "tags": ["ipmi", "сервер"], + "url": "#" + } + ] + } + ] +} diff --git a/examples/section-items/settings.json b/examples/section-items/settings.json new file mode 100644 index 0000000..342d127 --- /dev/null +++ b/examples/section-items/settings.json @@ -0,0 +1,4 @@ +{ + "dataUrl": "./panel-data.json", + "data": null +} diff --git a/examples/shared/assets/ceph.svg b/examples/shared/assets/ceph.svg new file mode 100644 index 0000000..4a8974f --- /dev/null +++ b/examples/shared/assets/ceph.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/config.svg b/examples/shared/assets/config.svg new file mode 100644 index 0000000..fa5878b --- /dev/null +++ b/examples/shared/assets/config.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/database.svg b/examples/shared/assets/database.svg new file mode 100644 index 0000000..578e7c7 --- /dev/null +++ b/examples/shared/assets/database.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/gitea.svg b/examples/shared/assets/gitea.svg new file mode 100644 index 0000000..83ba6e7 --- /dev/null +++ b/examples/shared/assets/gitea.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/grafana.svg b/examples/shared/assets/grafana.svg new file mode 100644 index 0000000..8546558 --- /dev/null +++ b/examples/shared/assets/grafana.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/kubernetes.svg b/examples/shared/assets/kubernetes.svg new file mode 100644 index 0000000..db237aa --- /dev/null +++ b/examples/shared/assets/kubernetes.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/ldap.svg b/examples/shared/assets/ldap.svg new file mode 100644 index 0000000..61ada3e --- /dev/null +++ b/examples/shared/assets/ldap.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/messages.svg b/examples/shared/assets/messages.svg new file mode 100644 index 0000000..8dddb1a --- /dev/null +++ b/examples/shared/assets/messages.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/opensearch.svg b/examples/shared/assets/opensearch.svg new file mode 100644 index 0000000..8cefb23 --- /dev/null +++ b/examples/shared/assets/opensearch.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/prometheus.svg b/examples/shared/assets/prometheus.svg new file mode 100644 index 0000000..1ff304a --- /dev/null +++ b/examples/shared/assets/prometheus.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/rabbitmq.svg b/examples/shared/assets/rabbitmq.svg new file mode 100644 index 0000000..d4996ea --- /dev/null +++ b/examples/shared/assets/rabbitmq.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/redmine.svg b/examples/shared/assets/redmine.svg new file mode 100644 index 0000000..5f3f737 --- /dev/null +++ b/examples/shared/assets/redmine.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/rules.svg b/examples/shared/assets/rules.svg new file mode 100644 index 0000000..3af5122 --- /dev/null +++ b/examples/shared/assets/rules.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/search.svg b/examples/shared/assets/search.svg new file mode 100644 index 0000000..bc0d852 --- /dev/null +++ b/examples/shared/assets/search.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/server.svg b/examples/shared/assets/server.svg new file mode 100644 index 0000000..24f4173 --- /dev/null +++ b/examples/shared/assets/server.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/xmpp.svg b/examples/shared/assets/xmpp.svg new file mode 100644 index 0000000..aa1376b --- /dev/null +++ b/examples/shared/assets/xmpp.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/assets/xpath.svg b/examples/shared/assets/xpath.svg new file mode 100644 index 0000000..b87630a --- /dev/null +++ b/examples/shared/assets/xpath.svg @@ -0,0 +1 @@ + diff --git a/examples/shared/script.js b/examples/shared/script.js new file mode 100644 index 0000000..1f6ff40 --- /dev/null +++ b/examples/shared/script.js @@ -0,0 +1,1139 @@ +// ============================================================================ +// 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 = { + ceph: { label: "Ceph", icon: "assets/ceph.svg" }, + cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" }, + config: { label: "Config", icon: "assets/config.svg" }, + gitea: { label: "Gitea", icon: "assets/gitea.svg" }, + grafana: { label: "Grafana", icon: "assets/grafana.svg" }, + ipmi: { label: "IPMI", icon: "assets/server.svg" }, + kubernetes: { label: "Kubernetes", icon: "assets/kubernetes.svg" }, + ldap: { label: "LDAP", icon: "assets/ldap.svg" }, + messages: { label: "Messages", icon: "assets/messages.svg" }, + opensearch: { label: "OpenSearch", icon: "assets/opensearch.svg" }, + postgresql: { label: "PostgreSQL", icon: "assets/database.svg" }, + prometheus: { label: "Prometheus", icon: "assets/prometheus.svg" }, + rabbitmq: { label: "RabbitMQ", icon: "assets/rabbitmq.svg" }, + redmine: { label: "Redmine", icon: "assets/redmine.svg" }, + rules: { label: "Rules", icon: "assets/rules.svg" }, + xpath: { label: "xPath", icon: "assets/xpath.svg" }, + xmpp: { label: "XMPP", icon: "assets/xmpp.svg" }, +}; + +let serviceMeta = { ...defaultServiceMeta }; + +// ============================================================================ +// Default UI preferences +// ============================================================================ +// Значения используются при первом открытии или если localStorage пуст/битый. +const defaultPreferences = { + theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", + view: "cards", + accent: "violet", + customAccentFrom: "#8b5cf6", + customAccentTo: "#22d3ee", + customAccentAngle: 135, + showNumbers: false, + showTreeTags: true, + treeTwoColumn: false, + autoCollapseSearch: true, + sidebarWidth: 390, +}; +const availableViews = ["cards", "list", "compact-cards", "compact-list"]; +const availableAccents = ["violet", "cyan", "lime", "amber", "custom"]; + +// ============================================================================ +// Runtime state and DOM references +// ============================================================================ +// preferences - текущие настройки интерфейса, объединенные с localStorage. +const preferences = { ...defaultPreferences, ...readPreferences() }; + +// collapseState - объект вида { [collapseKey]: boolean } для details. +const collapseState = readCollapseState(); + +// Корневые DOM-узлы, которые нужны для рендера и событий. +const catalog = document.querySelector("[data-catalog-root]"); +const navTree = document.querySelector("[data-nav-tree]"); +const sidebarResizer = document.querySelector("[data-sidebar-resizer]"); +const searchInput = document.querySelector("[data-search-input]"); +const settingsToggle = document.querySelector("[data-settings-toggle]"); +const settingsPanel = document.querySelector("[data-settings-panel]"); +const settingsOverlay = document.querySelector("[data-settings-overlay]"); +const settingsClose = document.querySelector("[data-settings-close]"); +const themeButtons = document.querySelectorAll("[data-theme]"); +const viewButtons = document.querySelectorAll("[data-view]"); +const accentButtons = document.querySelectorAll("[data-accent]"); +const accentCustomPanel = document.querySelector("[data-accent-custom-panel]"); +const customAccentFromInput = document.querySelector("[data-custom-accent-from]"); +const customAccentToInput = document.querySelector("[data-custom-accent-to]"); +const customAccentAngleInput = document.querySelector("[data-custom-accent-angle]"); +const customAccentAngleValue = document.querySelector("[data-custom-accent-angle-value]"); +const customAccentPreview = document.querySelector("[data-custom-accent-preview]"); +const showNumbersInput = document.querySelector("[data-show-numbers]"); +const showTreeTagsInput = document.querySelector("[data-show-tree-tags]"); +const treeTwoColumnInput = document.querySelector("[data-tree-two-column]"); +const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]"); +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 panelData = await loadPanelConfig(); + 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", () => { + preferences.theme = button.dataset.theme; + applyPreferences(preferences); + savePreferences(preferences); + }); +}); + +// Переключение режима отображения каталога. +viewButtons.forEach((button) => { + button.addEventListener("click", () => { + preferences.view = button.dataset.view; + applyPreferences(preferences); + savePreferences(preferences); + }); +}); + +// Выбор цветового акцента из пресетов или пользовательского градиента. +accentButtons.forEach((button) => { + button.addEventListener("click", () => { + preferences.accent = button.dataset.accent; + applyPreferences(preferences); + savePreferences(preferences); + }); +}); + +// Изменение параметров своего градиента сразу переключает accent на custom. +function handleCustomAccentChange() { + preferences.customAccentFrom = customAccentFromInput.value; + preferences.customAccentTo = customAccentToInput.value; + preferences.customAccentAngle = Number(customAccentAngleInput.value); + preferences.accent = "custom"; + applyPreferences(preferences); + savePreferences(preferences); +} + +customAccentFromInput.addEventListener("input", handleCustomAccentChange); +customAccentToInput.addEventListener("input", handleCustomAccentChange); +customAccentAngleInput.addEventListener("input", handleCustomAccentChange); + +// Показывает/скрывает номера в секциях, группах и карточках. +showNumbersInput.addEventListener("change", () => { + preferences.showNumbers = showNumbersInput.checked; + applyPreferences(preferences); + savePreferences(preferences); +}); + +// Показывает/скрывает поисковые теги в файловом дереве. +// Взаимоисключимо с двухколоночным режимом элементов папок. +showTreeTagsInput.addEventListener("change", () => { + preferences.showTreeTags = showTreeTagsInput.checked; + if (preferences.showTreeTags) { + preferences.treeTwoColumn = false; + } + applyPreferences(preferences); + savePreferences(preferences); +}); + +// Компактный двухколоночный режим элементов внутри папок дерева. +// Взаимоисключимо с отображением тегов. +treeTwoColumnInput.addEventListener("change", () => { + preferences.treeTwoColumn = treeTwoColumnInput.checked; + if (preferences.treeTwoColumn) { + preferences.showTreeTags = false; + if (preferences.sidebarWidth < 480) { + preferences.sidebarWidth = 480; + } + } + applyPreferences(preferences); + savePreferences(preferences); +}); + +// Включает/выключает авто-раскрытие веток дерева при поиске. +autoCollapseSearchInput.addEventListener("change", () => { + preferences.autoCollapseSearch = autoCollapseSearchInput.checked; + applyPreferences(preferences); + renderNavigationTree(); + savePreferences(preferences); +}); + +// Drag-resize бокового меню. +// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную. +sidebarResizer.addEventListener("pointerdown", (event) => { + event.preventDefault(); + document.body.classList.add("is-resizing-sidebar"); + sidebarResizer.setPointerCapture(event.pointerId); + + const handlePointerMove = (moveEvent) => { + const layoutLeft = document.querySelector(".app-layout").getBoundingClientRect().left; + preferences.sidebarWidth = Math.round( + clamp(moveEvent.clientX - layoutLeft, 260, 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); +}); + +// ============================================================================ +// Panel config loading +// ============================================================================ +async function loadPanelConfig(settingsPath = SETTINGS_PATH) { + const settings = await fetchJson(settingsPath); + 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); +} + +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() : "#", + }; +} + +function slugifyItemKey(value) { + return normalize(String(value)) + .replace(/[^a-z0-9\u0430-\u044f\u0451]+/g, "-") + .replace(/^-+|-+$/g, "") || "item"; +} + +function showConfigError(error) { + const message = error instanceof Error ? error.message : String(error); + navTree.replaceChildren(); + catalog.replaceChildren(); + + if (configErrorTemplate) { + const node = configErrorTemplate.content.cloneNode(true); + node.querySelector("[data-config-error-message]").textContent = + `${message}. Проверь settings.json и доступность dataUrl.`; + catalog.append(node); + return; + } + + catalog.textContent = message; +} + +// ============================================================================ +// Data helpers +// ============================================================================ +// Упрощает запись item-объектов при ручной правке JS (legacy helper). +function createItem(number, title, type, tags = [], url = "#") { + return normalizeItemFromConfig({ number, title, type, tags, url }, 0); +} + +// ============================================================================ +// Catalog rendering +// ============================================================================ +// Рендерит правую часть: details-разделы, details-подразделы и карточки. +// При активном поиске: +// - оставляет только группы с найденными элементами; +// - принудительно раскрывает найденные ветки; +// - подсвечивает совпадения через highlightText(). +function renderCatalog() { + const query = normalize(searchInput.value); + const forceOpen = Boolean(query); + const fragment = document.createDocumentFragment(); + let visibleItems = 0; + + ecosystemSections.forEach((section) => { + if (section.flat && !section.groups.length) { + const filteredItems = section.items.filter((item) => matchesQuery(item, null, section, query)); + + if (!filteredItems.length) { + return; + } + + fragment.append(createItemsGridElement(filteredItems, query, "items-grid catalog-flat-grid")); + visibleItems += filteredItems.length; + return; + } + + const sectionElement = createSectionElement(section, forceOpen, query); + let sectionVisibleItems = 0; + const sectionContainer = sectionElement.querySelector(".section-groups"); + + const filteredDirectItems = section.items.filter((item) => matchesQuery(item, null, section, query)); + if (filteredDirectItems.length) { + sectionVisibleItems += filteredDirectItems.length; + sectionContainer.append(createItemsGridElement(filteredDirectItems, query)); + } + + section.groups.forEach((group) => { + const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query)); + + if (!filteredItems.length) { + return; + } + + sectionVisibleItems += filteredItems.length; + sectionContainer.append(createGroupElement(group, filteredItems, forceOpen, query)); + }); + + if (sectionVisibleItems) { + sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems; + fragment.append(sectionElement); + visibleItems += sectionVisibleItems; + } + }); + + catalog.replaceChildren(); + + if (visibleItems) { + catalog.append(fragment); + attachCardInteractions(); + } else { + catalog.append(emptyTemplate.content.cloneNode(true)); + } +} + +function createItemsGridElement(items, query = "", className = "items-grid") { + const grid = document.createElement("div"); + grid.className = className; + grid.append(...items.map((item) => createCardElement(item, query))); + return grid; +} + +// Создает корневой details для раздела контента. +// forceOpen нужен для поиска: найденные ветки должны быть видимы независимо +// от сохраненного collapsed-состояния. +function createSectionElement(section, forceOpen = false, query = "") { + const sectionElement = document.createElement("details"); + sectionElement.className = "catalog-section"; + sectionElement.id = getSectionId(section); + sectionElement.dataset.collapseKey = sectionElement.id; + sectionElement.tabIndex = -1; + sectionElement.open = forceOpen || getSavedOpenState(sectionElement.dataset.collapseKey); + sectionElement.innerHTML = ` + +
+

${highlightText(section.number, query)} раздел

+

${highlightText(section.title, query)}

+

${highlightText(section.description, query)}

+
+ +
+
+ `; + 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 = ` + +
+

${highlightText(group.number, query)}

+

${highlightText(group.title, query)}

+

${highlightText(group.description, query)}

+
+ + ${items.length} + + +
+ `; + + const grid = document.createElement("div"); + grid.className = "items-grid"; + grid.append(...cards); + groupElement.append(grid); + + return groupElement; +} + +// Создает конечную карточку навигации. +// Если url === "#", карточка считается заглушкой и не меняет текущий hash/scroll. +function createCardElement(item, query = "") { + const meta = serviceMeta[item.type] || serviceMeta.config; + const link = document.createElement("a"); + link.className = "nav-card"; + link.href = item.url; + link.id = getItemId(item); + link.dataset.type = item.type; + + if (item.url === "#") { + link.setAttribute("aria-label", `${item.title}: URL еще не настроен`); + link.addEventListener("click", (event) => event.preventDefault()); + } else { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + + link.innerHTML = ` + + +
+

${highlightText(item.number, query)}

+

${highlightText(item.title, query)}

+

${highlightText(meta.label, query)}

+
${item.tags.map((tag) => `${highlightText(tag, query)}`).join("")}
+
+ + `; + + 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() { + 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 = ` + + + `; + 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 = ` + + + `; + 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) => { + const itemButton = document.createElement("button"); + itemButton.type = "button"; + itemButton.className = "tree-node tree-file"; + itemButton.dataset.focusTarget = getItemId(item); + itemButton.innerHTML = `${highlightText(item.title, query)}${highlightText(item.tags.join(", "), query)}`; + itemList.append(itemButton); + }); + + return itemList; +} + +// ============================================================================ +// Focusing from tree to content +// ============================================================================ +// Переход из дерева к правому контенту: +// - если текущий поиск скрывает цель, поиск очищается; +// - родительские details раскрываются; +// - состояние раскрытия сохраняется; +// - целевой элемент скроллится в центр и подсвечивается. +function focusCatalogTarget(targetId) { + let target = document.getElementById(targetId); + + if (!target) { + searchInput.value = ""; + renderCatalog(); + target = document.getElementById(targetId); + } + + if (!target) { + return; + } + + target.closest(".catalog-section")?.setAttribute("open", ""); + target.closest(".catalog-group")?.setAttribute("open", ""); + persistOpenedAncestor(target.closest(".catalog-section")); + persistOpenedAncestor(target.closest(".catalog-group")); + target.scrollIntoView({ behavior: "smooth", block: "center" }); + target.focus({ preventScroll: true }); + target.classList.add("is-focused"); + window.setTimeout(() => target.classList.remove("is-focused"), 1200); +} + +// ============================================================================ +// Collapse state handling +// ============================================================================ +// Сохраняет состояние каждого details, у которого есть data-collapse-key. +function handleDetailsToggle(event) { + const details = event.target; + + if (!(details instanceof HTMLDetailsElement) || !details.dataset.collapseKey) { + return; + } + + collapseState[details.dataset.collapseKey] = details.open; + saveCollapseState(); +} + +// Принудительно сохраняет родительский details как раскрытый. +// Используется, когда пользователь прыгает к элементу из дерева. +function persistOpenedAncestor(details) { + if (!details?.dataset.collapseKey) { + return; + } + + collapseState[details.dataset.collapseKey] = true; + saveCollapseState(); +} + +// ============================================================================ +// Card pointer interaction +// ============================================================================ +// Добавляет мягкий tilt/highlight эффект для карточек. +// После каждого renderCatalog() обработчики нужно навешивать заново, +// потому что карточки пересоздаются. +function attachCardInteractions() { + document.querySelectorAll(".nav-card").forEach((card) => { + let leaveTimer; + + card.addEventListener("pointerenter", () => { + window.clearTimeout(leaveTimer); + card.classList.add("is-hovered"); + }); + + card.addEventListener("pointermove", (event) => { + window.clearTimeout(leaveTimer); + card.classList.add("is-hovered"); + + const rect = card.getBoundingClientRect(); + const xPercent = clamp(((event.clientX - rect.left) / rect.width) * 100, 8, 92); + const yPercent = clamp(((event.clientY - rect.top) / rect.height) * 100, 8, 92); + const rotateY = ((xPercent - 50) / 50) * 2.4; + const rotateX = ((50 - yPercent) / 50) * 2.4; + + card.style.setProperty("--card-x", `${xPercent}%`); + card.style.setProperty("--card-y", `${yPercent}%`); + card.style.setProperty("--tilt-x", `${rotateX}deg`); + card.style.setProperty("--tilt-y", `${rotateY}deg`); + }); + + card.addEventListener("pointerleave", () => { + leaveTimer = window.setTimeout(() => { + card.classList.remove("is-hovered"); + card.style.setProperty("--card-x", "50%"); + card.style.setProperty("--card-y", "50%"); + card.style.setProperty("--tilt-x", "0deg"); + card.style.setProperty("--tilt-y", "0deg"); + }, 80); + }); + }); +} + +// ============================================================================ +// Preferences application +// ============================================================================ +// Применяет preferences к DOM: +// - data-theme / data-accent для CSS; +// - CSS-переменную ширины сайдбара; +// - класс режима отображения каталога; +// - состояния toggle-контролов в настройках. +function applyPreferences(nextPreferences) { + if (!availableViews.includes(nextPreferences.view)) { + nextPreferences.view = defaultPreferences.view; + } + + if (!availableAccents.includes(nextPreferences.accent)) { + nextPreferences.accent = defaultPreferences.accent; + } + + nextPreferences.customAccentFrom = normalizeHexColor(nextPreferences.customAccentFrom) || defaultPreferences.customAccentFrom; + nextPreferences.customAccentTo = normalizeHexColor(nextPreferences.customAccentTo) || defaultPreferences.customAccentTo; + nextPreferences.customAccentAngle = clamp( + Number(nextPreferences.customAccentAngle) || defaultPreferences.customAccentAngle, + 0, + 360, + ); + + 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.style.setProperty( + "--sidebar-width", + `${clamp( + Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth, + 260, + getSidebarMaxWidth(nextPreferences), + )}px`, + ); + document.documentElement.style.setProperty( + "--shell-max-width", + nextPreferences.treeTwoColumn ? "1920px" : "1560px", + ); + document.documentElement.style.setProperty( + "--sidebar-max-width", + `${getSidebarMaxWidth(nextPreferences)}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); +} + +// Применяет 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 += `${escapeHTML(text.slice(matchIndex, matchIndex + normalizedQuery.length))}`; + 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); +} + +// Максимальная ширина sidebar зависит от режима дерева. +function getSidebarMaxWidth(nextPreferences) { + return nextPreferences.treeTwoColumn ? 760 : 560; +} + +// ============================================================================ +// 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"); +} diff --git a/examples/shared/styles.css b/examples/shared/styles.css new file mode 100644 index 0000000..3f26ecf --- /dev/null +++ b/examples/shared/styles.css @@ -0,0 +1,1542 @@ +/* ========================================================================== + 01. Theme Tokens + -------------------------------------------------------------------------- + Все ключевые цвета и размеры вынесены в CSS-переменные. JavaScript меняет + data-theme/data-accent и --sidebar-width, а остальная верстка реагирует + без прямого изменения inline-стилей у отдельных элементов. + ========================================================================== */ + +:root { + color-scheme: dark; + --bg: #080a12; + --bg-soft: #111827; + --panel: rgba(15, 23, 42, 0.74); + --panel-strong: rgba(15, 23, 42, 0.92); + --surface: rgba(255, 255, 255, 0.055); + --text: #f8fafc; + --muted: #aeb8c8; + --border: rgba(255, 255, 255, 0.12); + --shadow: 0 24px 80px rgba(0, 0, 0, 0.34); + --accent: #8b5cf6; + --accent-2: #22d3ee; + --accent-rgb: 139, 92, 246; + --accent-angle: 135deg; + --card-x: 50%; + --card-y: 50%; + --sidebar-width: 390px; + --shell-max-width: 1560px; + --sidebar-max-width: 560px; +} + +/* Светлая тема переопределяет только токены, структура стилей остается общей. */ +html[data-theme="light"] { + color-scheme: light; + --bg: #f5f7fb; + --bg-soft: #ffffff; + --panel: rgba(255, 255, 255, 0.78); + --panel-strong: rgba(255, 255, 255, 0.96); + --surface: rgba(15, 23, 42, 0.045); + --text: #101827; + --muted: #5f6b7a; + --border: rgba(15, 23, 42, 0.12); + --shadow: 0 24px 70px rgba(56, 65, 91, 0.18); +} + +/* Наборы акцентов. --accent-rgb нужен для прозрачных градиентов через rgba(). */ +html[data-accent="cyan"] { + --accent: #06b6d4; + --accent-2: #3b82f6; + --accent-rgb: 6, 182, 212; +} + +html[data-accent="lime"] { + --accent: #84cc16; + --accent-2: #14b8a6; + --accent-rgb: 132, 204, 22; +} + +html[data-accent="amber"] { + --accent: #f59e0b; + --accent-2: #f43f5e; + --accent-rgb: 245, 158, 11; +} + +/* ========================================================================== + 02. Base Reset And Page Background + -------------------------------------------------------------------------- + Базовые правила страницы, плавная смена темы и фоновые декоративные слои. + ========================================================================== */ + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + margin: 0; + overflow-x: hidden; + background: + radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.24), transparent 36rem), + radial-gradient(circle at bottom right, rgba(34, 211, 238, 0.16), transparent 34rem), + linear-gradient(135deg, var(--bg), var(--bg-soft)); + color: var(--text); + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + transition: background 220ms ease, color 220ms ease; +} + +button, +input, +a { + font: inherit; +} + +a { + color: inherit; +} + +/* Фоновые "ambient" пятна создают глубину, но не перехватывают клики. */ +.ambient { + position: fixed; + z-index: -1; + width: 30rem; + height: 30rem; + border-radius: 999px; + filter: blur(18px); + opacity: 0.42; + pointer-events: none; +} + +.ambient-one { + top: -10rem; + right: -8rem; + background: rgba(var(--accent-rgb), 0.46); +} + +.ambient-two { + bottom: -13rem; + left: -10rem; + background: rgba(34, 211, 238, 0.3); +} + +/* ========================================================================== + 03. Application Layout + -------------------------------------------------------------------------- + .shell ограничивает общую ширину, .app-layout делит экран на: + sidebar -> resizer -> catalog. + ========================================================================== */ + +.shell { + width: min(var(--shell-max-width), calc(100% - 28px)); + margin: 0 auto; + padding: 20px 0; +} + +/* Левая колонка получает ширину из --sidebar-width, которую меняет JS. */ +.app-layout { + display: grid; + grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr); + gap: 8px; + align-items: start; +} + +/* ========================================================================== + 04. Sidebar Resizer + -------------------------------------------------------------------------- + Узкая колонка между меню и каталогом. Pointer Events в script.js двигают + CSS-переменную --sidebar-width и сохраняют значение в localStorage. + ========================================================================== */ + +.sidebar-resizer { + position: sticky; + top: 20px; + width: 8px; + height: calc(100vh - 40px); + border-radius: 999px; + cursor: col-resize; + background: transparent; + transition: background 160ms ease; +} + +.sidebar-resizer::before { + content: ""; + display: block; + width: 2px; + height: 100%; + margin: 0 auto; + border-radius: inherit; + background: var(--border); +} + +.sidebar-resizer:hover::before, +body.is-resizing-sidebar .sidebar-resizer::before { + background: var(--accent); +} + +/* Во время drag меняем курсор по всей странице и запрещаем выделение текста. */ +body.is-resizing-sidebar { + cursor: col-resize; + user-select: none; +} + +/* ========================================================================== + 05. Sidebar Panel + -------------------------------------------------------------------------- + Sticky-панель слева: заголовок, шестеренка, поиск и дерево навигации. + ========================================================================== */ + +.side-panel { + position: sticky; + top: 20px; + min-width: 0; +} + +/* .hero остался как совместимый контейнерный класс после уплотнения шапки. */ +.hero { + display: block; + margin-bottom: 12px; +} + +/* Общая "стеклянная" поверхность для главных панелей. */ +.hero-content, +.control-panel, +.catalog-section, +.empty-state { + border: 1px solid var(--border); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(22px); +} + +.hero-content { + max-height: calc(100vh - 40px); + min-height: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + padding: clamp(16px, 2.6vw, 24px); + border-radius: 22px; + position: relative; + overflow: visible; +} + +.side-panel .hero-content { + justify-content: flex-start; + overflow: auto; +} + +/* Внутренний фон sidebar: градиент + hexagon pattern. */ +.hero-content::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(120deg, rgba(var(--accent-rgb), 0.25), transparent 34%), + url("data:image/svg+xml,%3Csvg width='96' height='84' viewBox='0 0 96 84' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' stroke='white' stroke-opacity='.10' stroke-width='1.4'%3E%3Cpath d='M24 2 46 14v25L24 52 2 39V14L24 2Z'/%3E%3Cpath d='M72 32 94 44v25L72 82 50 69V44l22-12Z'/%3E%3C/g%3E%3C/svg%3E"); + mask-image: linear-gradient(to bottom, black, transparent 88%); + pointer-events: none; +} + +.hero-content > * { + position: relative; +} + +/* Общий стиль маленьких uppercase-меток и номеров. */ +.eyebrow, +.section-number, +.group-number, +.item-number { + margin: 0; + color: var(--accent); + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +/* Нумерация управляется toggle-настройкой "Показывать нумерацию". */ +html:not([data-show-numbers="true"]) .section-number, +html:not([data-show-numbers="true"]) .group-number, +html:not([data-show-numbers="true"]) .item-number { + display: none; +} + +/* Теги в дереве управляются toggle-настройкой "Показывать теги дерева". */ +html[data-show-tree-tags="false"] .tree-node small { + display: none; +} + +/* Двухколоночный режим элементов внутри папок дерева. */ +html[data-tree-two-column="true"] { + --shell-max-width: 1920px; + --sidebar-max-width: 760px; +} + +html[data-tree-two-column="true"] .tree-items { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 8px; +} + +html[data-tree-two-column="true"] .tree-node.tree-file { + grid-template-columns: 16px minmax(0, 1fr); + min-height: 24px; + font-size: 0.8rem; +} + +html[data-tree-two-column="true"] .tree-node.tree-file small { + display: none; +} + +/* Подсветка совпадений поиска в дереве и в карточках. */ +.search-mark { + border-radius: 4px; + padding: 0 2px; + color: white; + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); +} + +/* ========================================================================== + 06. Settings Popup + -------------------------------------------------------------------------- + Настройки вынесены из sidebar в fixed popup, чтобы на мобильных устройствах + они не перекрывались потоком контента и всегда были поверх страницы. + ========================================================================== */ + +.control-panel { + position: fixed; + z-index: 1001; + top: 24px; + right: 24px; + width: min(460px, calc(100% - 48px)); + max-height: calc(100vh - 48px); + overflow: auto; + display: grid; + gap: 10px; + padding: 14px; + border-radius: 24px; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(-8px) scale(0.98); + transform-origin: top right; + transition: + opacity 180ms ease, + visibility 180ms ease, + transform 180ms ease; +} + +/* Затемнение и blur-подложка за открытыми настройками. */ +.settings-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(2, 6, 23, 0.42); + opacity: 0; + visibility: hidden; + pointer-events: none; + backdrop-filter: blur(4px); + transition: opacity 180ms ease, visibility 180ms ease; +} + +/* Состояния открытия popup и overlay. */ +.settings-overlay.is-open { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.control-panel.is-open { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0) scale(1); +} + +/* Кнопки шестеренки и закрытия имеют один визуальный язык. */ +.settings-button, +.settings-close { + border: 1px solid var(--border); + cursor: pointer; + color: var(--text); + background: var(--surface); + transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; +} + +.settings-button { + position: absolute; + z-index: 21; + top: 16px; + right: 16px; + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 999px; + font-size: 1.18rem; +} + +.settings-button:hover, +.settings-close:hover { + transform: translateY(-1px); + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.12); +} + +/* Заголовок popup-настроек. */ +.settings-panel-head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; +} + +.settings-panel-head h2 { + margin: 4px 0 0; + font-size: 1.35rem; + letter-spacing: -0.04em; +} + +.settings-close { + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 999px; + font-size: 1.25rem; + line-height: 1; +} + +/* ========================================================================== + 07. Search Field And Control Inputs + -------------------------------------------------------------------------- + Поиск живет в sidebar, а остальные контролы в popup. Здесь собраны общие + стили input, toggle-row, segmented buttons и accent swatches. + ========================================================================== */ + +.search-field { + margin-top: 15px; + + display: grid; + gap: 6px; + max-width: 720px; + position: relative; +} + +.control-group > span { + color: var(--muted); + font-size: 0.82rem; + font-weight: 800; +} + +/* Иконка поиска рисуется CSS-ом, чтобы не добавлять лишний DOM. */ +.search-field::after { + content: "⌕"; + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--accent); + font-size: 1.05rem; + font-weight: 900; + pointer-events: none; +} + +.search-field input { + width: 100%; + min-height: 38px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + color: var(--text); + outline: none; + padding: 0 34px 0 12px; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.search-field input:focus { + border-color: rgba(var(--accent-rgb), 0.62); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16); + background: var(--panel-strong); +} + +/* Toggle-row используется для checkbox-настроек с описанием. */ +.toggle-row { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + min-height: 54px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); +} + +.toggle-row span { + display: grid; + gap: 2px; +} + +.toggle-row strong { + font-size: 0.9rem; +} + +.toggle-row small { + color: var(--muted); + font-weight: 700; +} + +/* Кастомный checkbox выглядит как компактный switch. */ +.toggle-row input { + appearance: none; + width: 48px; + height: 28px; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(148, 163, 184, 0.26); + cursor: pointer; + position: relative; + transition: background 160ms ease, border-color 160ms ease; +} + +.toggle-row input::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 999px; + background: white; + transition: transform 160ms ease; +} + +.toggle-row input:checked { + border-color: rgba(var(--accent-rgb), 0.6); + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); +} + +.toggle-row input:checked::before { + transform: translateX(20px); +} + +/* Контейнеры групп кнопок настроек. */ +.control-group { + display: flex; + align-items: center; + gap: 6px; + min-height: 42px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); +} + +.control-group > span { + min-width: 68px; + padding-left: 6px; +} + +/* Для четырех режимов отображения кнопки складываются в компактную сетку. */ +.view-group { + flex-wrap: wrap; +} + +.view-group > span { + width: 100%; +} + +.view-group .segmented { + flex-basis: calc(50% - 3px); +} + +/* Segmented buttons и color swatches имеют общий focus/hover baseline. */ +.segmented, +.accent-swatch { + border: 0; + cursor: pointer; + transition: transform 160ms ease, background 160ms ease, color 160ms ease; +} + +.segmented { + flex: 1; + min-height: 31px; + min-width: 0; + border-radius: 12px; + background: transparent; + color: var(--muted); + font-size: 0.84rem; + font-weight: 800; +} + +.segmented:hover, +.accent-swatch:hover { + transform: translateY(-1px); +} + +.segmented.is-active { + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); + color: white; +} + +.accent-group { + flex-wrap: wrap; + justify-content: flex-start; + row-gap: 8px; +} + +.accent-group > span { + width: 100%; +} + +.accent-swatch { + width: 32px; + height: 32px; + border: 2px solid transparent; + border-radius: 999px; + box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.18); +} + +/* Акцентные кружки используют те же цвета, что и html[data-accent]. */ +.accent-swatch[data-accent="violet"] { + background: linear-gradient(135deg, #8b5cf6, #22d3ee); +} + +.accent-swatch[data-accent="cyan"] { + background: linear-gradient(135deg, #06b6d4, #3b82f6); +} + +.accent-swatch[data-accent="lime"] { + background: linear-gradient(135deg, #84cc16, #14b8a6); +} + +.accent-swatch[data-accent="amber"] { + background: linear-gradient(135deg, #f59e0b, #f43f5e); +} + +.accent-swatch.is-active { + border-color: var(--text); + transform: scale(1.05); +} + +/* Кнопка своего градиента: pill, пунктир, подпись и превию через CSS-переменные. */ +.accent-swatch-custom { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + width: auto; + min-width: 58px; + height: 32px; + padding: 0 10px; + border: 2px dashed rgba(255, 255, 255, 0.42); + box-shadow: none; + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.08) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.08) 75%), + linear-gradient(45deg, rgba(255, 255, 255, 0.08) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.08) 75%); + background-size: 8px 8px; + background-position: 0 0, 4px 4px; +} + +html[data-theme="light"] .accent-swatch-custom { + border-color: rgba(15, 23, 42, 0.28); + background: + linear-gradient(45deg, rgba(15, 23, 42, 0.06) 25%, transparent 25%, transparent 75%, rgba(15, 23, 42, 0.06) 75%), + linear-gradient(45deg, rgba(15, 23, 42, 0.06) 25%, transparent 25%, transparent 75%, rgba(15, 23, 42, 0.06) 75%); + background-size: 8px 8px; + background-position: 0 0, 4px 4px; +} + +.accent-swatch-custom::before { + content: ""; + position: absolute; + inset: 2px; + border-radius: inherit; + background: linear-gradient( + var(--custom-accent-angle, 135deg), + var(--custom-accent-from, #8b5cf6), + var(--custom-accent-to, #22d3ee) + ); +} + +.accent-swatch-label { + position: relative; + z-index: 1; + color: white; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.04em; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); +} + +.accent-swatch-custom.is-active { + border-style: solid; + border-color: var(--text); + transform: scale(1.05); +} + +/* Панель настройки своего градиента появляется при data-accent="custom". */ +.accent-custom-panel { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); +} + +.accent-custom-panel[hidden] { + display: none; +} + +.accent-custom-title { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.accent-custom-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.84rem; + font-weight: 700; +} + +.accent-custom-row input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +.accent-custom-row input[type="color"] { + width: 100%; + height: 34px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); + cursor: pointer; +} + +.accent-custom-row:has(input[type="color"]) { + grid-template-columns: 72px 1fr; +} + +.accent-custom-row output { + min-width: 3.2rem; + color: var(--accent); + font-size: 0.82rem; + font-weight: 800; + text-align: right; +} + +.accent-custom-preview { + height: 36px; + border: 1px solid var(--border); + border-radius: 12px; + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); +} + +/* ========================================================================== + 08. File-Tree Navigation + -------------------------------------------------------------------------- + Левая навигация визуально имитирует дерево файлов: + details/summary дают нативное раскрытие, а CSS добавляет линии, caret, + "folder" для разделов/групп и "file" для конечных элементов. + ========================================================================== */ + +.nav-tree { + display: grid; + gap: 2px; + min-height: 0; + padding: 4px 2px 0; + font-size: 0.86rem; +} + +/* Details в дереве intentionally выглядят не как карточки, а как вложенный список. */ +.tree-section, +.tree-group { + border: 0; + border-radius: 0; + background: transparent; + overflow: visible; +} + +.tree-group { + position: relative; + margin: 1px 0 1px 18px; + padding-left: 10px; + border-left: 1px solid var(--border); +} + +/* Summary получает caret, иконку папки и кликабельный текст. */ +.tree-section > summary, +.tree-group > summary { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 3px; + align-items: center; + list-style: none; + cursor: pointer; + min-height: 28px; + padding: 1px 2px; + border-radius: 9px; +} + +/* Нативный маркер details скрываем, так как рисуем свой caret. */ +.tree-section > summary::-webkit-details-marker, +.tree-group > summary::-webkit-details-marker { + display: none; +} + +/* Caret поворачивается через состояние [open] у details. */ +.tree-caret { + width: 16px; + height: 24px; + display: grid; + place-items: center; + color: var(--muted); +} + +.tree-caret::before { + content: "▾"; + font-size: 0.72rem; + transition: transform 140ms ease; +} + +.tree-section:not([open]) > summary .tree-caret::before, +.tree-group:not([open]) > summary .tree-caret::before { + transform: rotate(-90deg); +} + +/* Конечный элемент дерева: иконка файла, название и optional теги. */ +.tree-node { + min-width: 0; + width: 100%; + min-height: 26px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + border: 0; + border-radius: 8px; + cursor: pointer; + color: var(--text); + background: transparent; + text-align: left; + font: inherit; + font-weight: 760; +} + +.tree-node span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Вертикальная линия вложенности дерева. */ +.tree-node::before { + color: var(--accent); + opacity: 0.92; +} + +/* Папки и файлы нарисованы CSS-иконками, чтобы не тянуть отдельные SVG. */ +.tree-folder { + grid-template-columns: 18px minmax(0, 1fr); +} + +.tree-folder::before { + content: "📁"; + font-size: 0.9rem; +} + +.tree-file { + grid-template-columns: 18px minmax(0, 1fr) auto; +} + +.tree-file::before { + content: "📄"; + font-size: 0.86rem; +} + +/* Контейнер элементов внутри группы дерева. */ +.tree-items { + position: relative; + display: grid; + gap: 1px; + margin-left: 18px; + padding-left: 10px; + border-left: 1px solid var(--border); +} + +.tree-node:hover, +.tree-section > summary:hover, +.tree-group > summary:hover { + background: rgba(var(--accent-rgb), 0.14); +} + +/* Теги дерева усечены ellipsis, чтобы длинные списки не ломали sidebar. */ +.tree-node small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent); + font-size: 0.68rem; + font-weight: 800; +} + +/* ========================================================================== + 09. Catalog Structure + -------------------------------------------------------------------------- + Правая часть страницы. Разделы и подразделы тоже построены на details, + поэтому пользователь может сворачивать не только дерево, но и контент. + ========================================================================== */ + +.catalog { + display: grid; + gap: 12px; + min-width: 0; +} + +/* Главные glass-карточки разделов. */ +.catalog-section { + border-radius: 24px; + overflow: hidden; + scroll-margin-top: 18px; +} + +.catalog-section, +.catalog-group { + display: block; +} + +/* Summary у разделов/групп заменяет заголовок и одновременно управляет collapse. */ +.catalog-section > summary, +.catalog-group > summary { + list-style: none; + cursor: pointer; +} + +.catalog-section > summary::-webkit-details-marker, +.catalog-group > summary::-webkit-details-marker { + display: none; +} + +/* Шапка раздела: номер, название, описание, счетчик и индикатор collapse. */ +.section-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + padding: 18px; + border-bottom: 1px solid var(--border); + background: + radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.14), transparent 22rem), + rgba(255, 255, 255, 0.035); +} + +.section-meta, +.group-meta { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.section-head h2, +.group-head h3, +.nav-card h4, +.empty-state h2 { + margin: 0; + color: var(--text); + letter-spacing: -0.04em; +} + +.section-head h2 { + margin-top: 5px; + font-size: clamp(1.35rem, 3.2vw, 2.15rem); +} + +.section-head p:not(.section-number), +.group-head p:not(.group-number) { + max-width: 760px; + margin: 5px 0 0; + color: var(--muted); + line-height: 1.5; +} + +/* Счетчики помогают понять размер раздела без чтения всех карточек. */ +.section-count { + white-space: nowrap; + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 12px; + color: var(--muted); + background: var(--surface); + font-size: 0.86rem; + font-weight: 800; +} + +/* Стрелка сворачивания поворачивается через [open]. */ +.collapse-indicator { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--accent); + font-weight: 900; + transition: transform 160ms ease; +} + +.catalog-section:not([open]) > .section-head .collapse-indicator, +.catalog-group:not([open]) > .group-head .collapse-indicator { + transform: rotate(-90deg); +} + +/* Подразделы внутри секции. */ +.section-groups { + display: grid; + gap: 12px; + padding: 12px; +} + +/* Плоский список карточек без обёртки section/group. */ +.catalog > .catalog-flat-grid { + padding: 12px; +} + +/* Элементы раздела без промежуточного подраздела. */ +.section-groups > .items-grid:first-child:last-child { + padding: 0; +} + +/* Файлы сразу под разделом в дереве (без tree-group). */ +.tree-items-direct { + margin-left: 18px; + padding-left: 10px; + border-left: 1px solid var(--border); +} + +.catalog-group { + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; + scroll-margin-top: 18px; +} + +.group-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-bottom: 1px solid var(--border); +} + +.group-head h3 { + margin-top: 4px; + font-size: 1.08rem; +} + +.group-count { + width: 36px; + height: 36px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 999px; + background: rgba(var(--accent-rgb), 0.14); + color: var(--accent); + font-weight: 900; +} + +/* ========================================================================== + 10. Navigation Cards + -------------------------------------------------------------------------- + Конечные кликабельные элементы каталога. Карточки используют CSS-переменные + --card-x/--card-y, которые JS обновляет при движении мыши для shine-эффекта. + ========================================================================== */ + +.items-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 10px; +} + +/* Базовая карточка с иконкой, контентом, тегами, стрелкой и shine-слоем. */ +.nav-card { + --tilt-x: 0deg; + --tilt-y: 0deg; + position: relative; + min-height: 196px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--panel); + color: var(--text); + text-decoration: none; + overflow: hidden; + transform-origin: center; + transform: perspective(900px) rotateX(var(--tilt-x)) rotateY(var(--tilt-y)); + will-change: transform; + transition: + transform 260ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 220ms ease, + background 220ms ease, + box-shadow 220ms ease; + scroll-margin-top: 18px; +} + +/* Временная подсветка цели после клика в дереве. */ +.catalog-section.is-focused, +.catalog-group.is-focused, +.nav-card.is-focused { + border-color: rgba(var(--accent-rgb), 0.75); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16), var(--shadow); +} + +/* Градиентный бордер/подсветка под курсором. */ +.nav-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + circle at var(--card-x) var(--card-y), + rgba(var(--accent-rgb), 0.24), + transparent 34% + ); + opacity: 0; + transition: opacity 180ms ease; + pointer-events: none; +} + +/* Hover задается классом .is-hovered из JS, чтобы убрать скачки на границах. */ +.nav-card.is-hovered, +.nav-card:focus-visible { + border-color: rgba(var(--accent-rgb), 0.52); + background: var(--panel-strong); + box-shadow: 0 22px 70px rgba(var(--accent-rgb), 0.16); + outline: none; +} + +.nav-card.is-hovered::before, +.nav-card:focus-visible::before { + opacity: 1; +} + +/* Иконка сервиса берется из serviceMeta. */ +.nav-card img { + width: 50px; + height: 50px; + object-fit: contain; + filter: drop-shadow(0 14px 24px rgba(0, 0, 0, 0.22)); +} + +.card-content { + position: relative; + display: grid; + gap: 5px; +} + +.nav-card h4 { + font-size: 1rem; + line-height: 1.25; +} + +/* Тип сервиса выделен акцентом и помогает быстро считывать назначение. */ +.item-type { + margin: 0; + color: var(--accent); + font-size: 0.9rem; + font-weight: 750; +} + +/* Теги показывают, по каким словам карточку удобно искать. */ +.tags { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 2px; +} + +.tags span { + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 7px; + background: rgba(var(--accent-rgb), 0.1); + color: var(--muted); + font-size: 0.76rem; + font-weight: 800; +} + +/* Стрелка справа подчеркивает, что карточка ведет по ссылке. */ +.arrow { + position: absolute; + right: 16px; + bottom: 14px; + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 999px; + background: rgba(var(--accent-rgb), 0.14); + color: var(--accent); + font-size: 1.15rem; + transition: transform 180ms ease, background 180ms ease, color 180ms ease; +} + +.nav-card.is-hovered .arrow, +.nav-card:focus-visible .arrow { + transform: translateX(4px); + background: linear-gradient(var(--accent-angle), var(--accent), var(--accent-2)); + color: white; +} + +/* Декоративный shine слой, положение которого обновляется pointermove. */ +.shine { + position: absolute; + inset: 0; + background: linear-gradient( + 110deg, + transparent 0%, + rgba(255, 255, 255, 0.16) 42%, + transparent 64% + ); + transform: translateX(-130%); + transition: transform 540ms ease; + pointer-events: none; +} + +.nav-card.is-hovered .shine, +.nav-card:focus-visible .shine { + transform: translateX(130%); +} + +/* ========================================================================== + 11. View Modes + -------------------------------------------------------------------------- + Режимы отображения переключаются классами на .catalog: + cards-view, list-view, compact-cards-view, compact-list-view. + ========================================================================== */ + +.catalog.list-view .items-grid { + grid-template-columns: 1fr; +} + +/* В list-режимах карточка вытягивается в строку. */ +.catalog.list-view .nav-card, +.catalog.compact-list-view .nav-card { + min-height: 96px; + display: grid; + grid-template-columns: 50px minmax(0, 1fr) 36px; + align-items: center; + gap: 12px; +} + +.catalog.list-view .arrow, +.catalog.compact-list-view .arrow { + position: static; + justify-self: end; +} + +/* Compact modes скрывают второстепенную информацию и уменьшают отступы. */ +.catalog.compact-cards-view .items-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +/* Compact list на широких экранах использует 3 колонки. */ +.catalog.compact-list-view .items-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.catalog.compact-cards-view .section-head, +.catalog.compact-list-view .section-head { + padding: 14px 16px; +} + +/* В compact-режимах описания и теги скрыты ради плотности. */ +.catalog.compact-cards-view .section-head p:not(.section-number), +.catalog.compact-list-view .section-head p:not(.section-number), +.catalog.compact-cards-view .group-head p:not(.group-number), +.catalog.compact-list-view .group-head p:not(.group-number), +.catalog.compact-cards-view .tags, +.catalog.compact-list-view .tags, +.catalog.compact-list-view .item-type { + display: none; +} + +.catalog.compact-cards-view .section-head h2, +.catalog.compact-list-view .section-head h2 { + font-size: clamp(1.35rem, 3vw, 2rem); +} + +.catalog.compact-cards-view .group-head, +.catalog.compact-list-view .group-head { + padding: 10px 12px; + align-items: center; +} + +.catalog.compact-cards-view .group-head h3, +.catalog.compact-list-view .group-head h3 { + margin-top: 4px; + font-size: 1.05rem; +} + +.catalog.compact-cards-view .nav-card { + min-height: 116px; + gap: 8px; + padding: 12px; +} + +.catalog.compact-cards-view .nav-card img { + width: 44px; + height: 44px; +} + +.catalog.compact-cards-view .nav-card h4, +.catalog.compact-list-view .nav-card h4 { + font-size: 0.98rem; +} + +.catalog.compact-cards-view .item-type, +.catalog.compact-list-view .item-type { + font-size: 0.82rem; +} + +.catalog.compact-cards-view .arrow { + width: 30px; + height: 30px; + right: 12px; + bottom: 12px; +} + +.catalog.compact-list-view .nav-card { + min-height: 56px; + padding: 8px 10px; + grid-template-columns: 32px minmax(0, 1fr) 28px; +} + +.catalog.compact-list-view .nav-card img { + width: 32px; + height: 32px; +} + +.catalog.compact-list-view .item-number { + font-size: 0.72rem; +} + +/* ========================================================================== + 12. Empty State + -------------------------------------------------------------------------- + Показывается, когда фильтр поиска не оставил ни одной карточки. + ========================================================================== */ + +.empty-state { + min-height: 280px; + display: grid; + place-items: center; + align-content: center; + gap: 12px; + padding: 36px; + border-radius: 30px; + text-align: center; +} + +.empty-state img { + width: 74px; + height: 74px; +} + +.empty-state p { + max-width: 480px; + margin: 0; + color: var(--muted); +} + +/* ========================================================================== + 13. Responsive Layout + -------------------------------------------------------------------------- + Основной режим выбирается по типу ввода, а не по ширине экрана: + - touch / coarse pointer -> компактный "мобильный" layout; + - hover + fine pointer -> десктопный layout даже на широком touch-мониторе. + Узкое окно с мышью дополнительно поджимает сетку через max-width fallback. + ========================================================================== */ + +/* Узкое окно на устройствах с мышью: sidebar и сетка карточек чуть компактнее. */ +@media (hover: hover) and (pointer: fine) and (max-width: 1120px) { + .app-layout { + grid-template-columns: minmax(240px, min(var(--sidebar-width), 320px)) 8px minmax(0, 1fr); + } + + .items-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .catalog.compact-cards-view .items-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +/* Touch-устройства: stacked layout, без resizer, крупнее touch-targets. */ +@media (hover: none), (pointer: coarse) { + .shell { + width: min(var(--shell-max-width), calc(100% - 22px)); + padding: 14px 0; + } + + .app-layout { + grid-template-columns: 1fr; + } + + .sidebar-resizer { + display: none; + } + + .side-panel { + position: static; + } + + .hero-content { + min-height: auto; + max-height: none; + padding: 16px 14px; + border-radius: 20px; + } + + .nav-tree { + max-height: 320px; + overflow: auto; + } + + /* + Двухколоночное дерево на touch не отключается полностью: + - в portrait — всегда, если включена настройка; + - в landscape — начиная с 520px ширины viewport. + */ + html[data-tree-two-column="true"] .tree-items { + grid-template-columns: 1fr; + } + + @media (min-width: 520px), (orientation: portrait) { + html[data-tree-two-column="true"] .tree-items { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + .control-panel, + .catalog-section, + .empty-state { + border-radius: 24px; + } + + .control-panel { + top: 12px; + right: 12px; + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + } + + .control-group { + flex-wrap: wrap; + } + + .control-group .segmented { + flex-basis: calc(50% - 4px); + } + + .control-group > span { + width: 100%; + min-width: 0; + } + + .accent-group > span { + width: auto; + } + + .section-head, + .group-head { + display: grid; + } + + .section-count { + width: fit-content; + } + + .section-groups { + padding: 8px; + } + + .items-grid { + grid-template-columns: 1fr; + padding: 8px; + } + + .catalog.compact-list-view .items-grid { + grid-template-columns: 1fr; + } + + .catalog.compact-cards-view .items-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .nav-card { + min-height: 180px; + } + + .catalog.list-view .nav-card { + grid-template-columns: 48px minmax(0, 1fr); + min-height: 136px; + } + + .catalog.compact-list-view .nav-card { + grid-template-columns: 42px minmax(0, 1fr); + min-height: 84px; + } + + .catalog.list-view .arrow, + .catalog.compact-list-view .arrow { + position: absolute; + } + + .nav-card img { + width: 50px; + height: 50px; + } +} + +/* ========================================================================== + 14. Reduced Motion + -------------------------------------------------------------------------- + Уважает системную настройку пользователя и отключает анимации/плавный скролл. + ========================================================================== */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} diff --git a/index.html b/index.html index 61f57b3..137e831 100644 --- a/index.html +++ b/index.html @@ -247,6 +247,6 @@ - + diff --git a/panel-data-flat.example.json b/panel-data-flat.example.json new file mode 100644 index 0000000..6092a19 --- /dev/null +++ b/panel-data-flat.example.json @@ -0,0 +1,19 @@ +{ + "title": "Заголовок панели", + "items": [ + { + "id": "item-a", + "title": "Элемент A", + "type": "grafana", + "tags": ["тег"], + "url": "https://example.com/a" + }, + { + "id": "item-b", + "title": "Элемент B", + "type": "gitea", + "tags": ["тег"], + "url": "https://example.com/b" + } + ] +} diff --git a/panel-data-full.example.json b/panel-data-full.example.json new file mode 100644 index 0000000..e31aee7 --- /dev/null +++ b/panel-data-full.example.json @@ -0,0 +1,29 @@ +{ + "title": "Заголовок панели", + "sections": [ + { + "id": "section-a", + "number": "1", + "title": "Раздел", + "description": "Описание раздела", + "groups": [ + { + "id": "group-a", + "number": "1.1", + "title": "Подраздел", + "description": "Описание подраздела", + "items": [ + { + "id": "item-a", + "number": "1.1.1", + "title": "Элемент", + "type": "grafana", + "tags": ["тег"], + "url": "https://example.com" + } + ] + } + ] + } + ] +} diff --git a/panel-data-mixed.example.json b/panel-data-mixed.example.json new file mode 100644 index 0000000..950ae2a --- /dev/null +++ b/panel-data-mixed.example.json @@ -0,0 +1,33 @@ +{ + "title": "Заголовок панели", + "sections": [ + { + "id": "section-a", + "title": "Раздел", + "items": [ + { + "id": "item-direct", + "title": "Прямой элемент", + "type": "config", + "tags": ["быстрый доступ"], + "url": "https://example.com/direct" + } + ], + "groups": [ + { + "id": "group-a", + "title": "Подраздел", + "items": [ + { + "id": "item-nested", + "title": "Вложенный элемент", + "type": "kubernetes", + "tags": ["кластер"], + "url": "https://example.com/nested" + } + ] + } + ] + } + ] +} diff --git a/panel-data-section-items.example.json b/panel-data-section-items.example.json new file mode 100644 index 0000000..4a40eaa --- /dev/null +++ b/panel-data-section-items.example.json @@ -0,0 +1,26 @@ +{ + "title": "Заголовок панели", + "sections": [ + { + "id": "section-a", + "title": "Раздел", + "description": "Описание раздела", + "items": [ + { + "id": "item-a", + "title": "Элемент A", + "type": "prometheus", + "tags": ["тег"], + "url": "https://example.com/a" + }, + { + "id": "item-b", + "title": "Элемент B", + "type": "postgresql", + "tags": ["тег"], + "url": "https://example.com/b" + } + ] + } + ] +} diff --git a/script.js b/script.js index 7e2f450..1f6ff40 100644 --- a/script.js +++ b/script.js @@ -15,7 +15,18 @@ const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state"; // settings.json задает источник данных панели: // - dataUrl: URL или путь к JSON (fetch), например "./panel-data.json" // - data: inline-объект с той же структурой, если dataUrl пустой -const SETTINGS_PATH = "./settings.json"; +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 = "Панель экосистемы"; @@ -317,14 +328,39 @@ async function fetchJson(url) { } function normalizePanelData(raw) { - const sections = Array.isArray(raw.sections) ? raw.sections.map(normalizeSection) : []; + const rootItems = Array.isArray(raw.items) + ? raw.items.map((item, index) => normalizeItemFromConfig(item, index)) + : []; + let sections = Array.isArray(raw.sections) ? raw.sections.map(normalizeSection) : []; - if (!sections.length) { - throw new Error("В данных панели нет sections"); + // Корневой 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: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "Панель экосистемы", + title: panelTitle, sections, serviceMeta: { ...defaultServiceMeta, @@ -338,12 +374,23 @@ function normalizeSection(section) { 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: Array.isArray(section.groups) ? section.groups.map(normalizeGroup) : [], + groups, + items, + flat: Boolean(section.flat), }; } @@ -361,12 +408,19 @@ function normalizeGroup(group) { }; } -function normalizeItemFromConfig(item) { +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), @@ -375,6 +429,12 @@ function normalizeItemFromConfig(item) { }; } +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(); @@ -396,7 +456,7 @@ function showConfigError(error) { // ============================================================================ // Упрощает запись item-объектов при ручной правке JS (legacy helper). function createItem(number, title, type, tags = [], url = "#") { - return { number, title, type, tags, url }; + return normalizeItemFromConfig({ number, title, type, tags, url }, 0); } // ============================================================================ @@ -414,9 +474,27 @@ function renderCatalog() { 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 visibleGroups = 0; 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)); @@ -425,12 +503,11 @@ function renderCatalog() { return; } - visibleGroups += 1; sectionVisibleItems += filteredItems.length; - sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems, forceOpen, query)); + sectionContainer.append(createGroupElement(group, filteredItems, forceOpen, query)); }); - if (visibleGroups) { + if (sectionVisibleItems) { sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems; fragment.append(sectionElement); visibleItems += sectionVisibleItems; @@ -445,7 +522,13 @@ function renderCatalog() { } 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 для раздела контента. @@ -528,7 +611,7 @@ function createCardElement(item, query = "") { link.innerHTML = ` - +

${highlightText(item.number, query)}

${highlightText(item.title, query)}

@@ -559,7 +642,8 @@ function matchesQuery(item, group, section, query) { item.type, meta.label, item.tags.join(" "), - group.title, + group?.title ?? "", + group?.description ?? "", section.title, ].join(" "), ); @@ -567,6 +651,18 @@ function matchesQuery(item, group, section, query) { 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) { @@ -600,8 +696,18 @@ function renderNavigationTree() { const fragment = document.createDocumentFragment(); ecosystemSections.forEach((section) => { - const sectionOwnMatch = matchesText([section.number, section.title, section.description], query); - const sectionHasMatches = sectionOwnMatch || section.groups.some((group) => groupMatchesQuery(group, section, query)); + 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); @@ -616,6 +722,11 @@ function renderNavigationTree() { `; 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"); @@ -631,20 +742,7 @@ function renderNavigationTree() { `; groupNode.append(groupSummary); - - const itemList = document.createElement("div"); - itemList.className = "tree-items"; - - group.items.forEach((item) => { - const itemButton = document.createElement("button"); - itemButton.type = "button"; - itemButton.className = "tree-node tree-file"; - itemButton.dataset.focusTarget = getItemId(item); - itemButton.innerHTML = `${highlightText(item.title, query)}${highlightText(item.tags.join(", "), query)}`; - itemList.append(itemButton); - }); - - groupNode.append(itemList); + groupNode.append(createTreeItemList(group.items.filter((item) => matchesQuery(item, group, section, query)), query)); sectionNode.append(groupNode); }); @@ -654,6 +752,22 @@ function renderNavigationTree() { navTree.replaceChildren(fragment); } +function createTreeItemList(items, query = "", className = "tree-items") { + const itemList = document.createElement("div"); + itemList.className = className; + + items.forEach((item) => { + const itemButton = document.createElement("button"); + itemButton.type = "button"; + itemButton.className = "tree-node tree-file"; + itemButton.dataset.focusTarget = getItemId(item); + itemButton.innerHTML = `${highlightText(item.title, query)}${highlightText(item.tags.join(", "), query)}`; + itemList.append(itemButton); + }); + + return itemList; +} + // ============================================================================ // Focusing from tree to content // ============================================================================ @@ -967,7 +1081,7 @@ function getTreeGroupId(group) { } function getItemId(item) { - return `item-${item.number.replaceAll(".", "-")}`; + return `item-${String(item.itemKey).replaceAll(".", "-")}`; } // Ограничивает число диапазоном. diff --git a/styles.css b/styles.css index 0cbb510..3f26ecf 100644 --- a/styles.css +++ b/styles.css @@ -1016,6 +1016,23 @@ html[data-theme="light"] .accent-swatch-custom { padding: 12px; } +/* Плоский список карточек без обёртки section/group. */ +.catalog > .catalog-flat-grid { + padding: 12px; +} + +/* Элементы раздела без промежуточного подраздела. */ +.section-groups > .items-grid:first-child:last-child { + padding: 0; +} + +/* Файлы сразу под разделом в дереве (без tree-group). */ +.tree-items-direct { + margin-left: 18px; + padding-left: 10px; + border-left: 1px solid var(--border); +} + .catalog-group { border: 1px solid var(--border); border-radius: 18px;