более гибкая архитектура размещения элементов с примерами и документацией

This commit is contained in:
Илья Братских 2026-06-28 16:28:51 +03:00 committed by KBAHTNET
parent 91ca4e8d57
commit 284a7f6b75
42 changed files with 4463 additions and 96 deletions

222
README.md
View File

@ -1,9 +1,30 @@
# Панель экосистемы # Панель экосистемы
Статическая навигационная панель для быстрого доступа к сервисам инфраструктуры. Поддерживает иерархию **раздел → подраздел → элемент**, поиск, дерево навигации, несколько режимов отображения и настройку внешнего вида. Статическая навигационная панель для быстрого доступа к сервисам инфраструктуры. Поддерживает гибкую иерархию данных, поиск, дерево навигации, несколько режимов отображения и настройку внешнего вида.
Стек: HTML, CSS, vanilla JavaScript. Без сборщиков и фреймворков. Стек: HTML, CSS, vanilla JavaScript. Без сборщиков и фреймворков.
## Оглавление
- [Возможности](#возможности)
- [Быстрый старт](#быстрый-старт)
- [Структура проекта](#структура-проекта)
- [Конфигурация](#конфигурация)
- [`settings.json`](#settingsjson)
- [Форматы иерархии](#форматы-иерархии)
- [Схема `panel-data`](#схема-panel-data)
- [Поля данных](#поля-данных)
- [Примеры данных](#примеры-данных)
- [Готовые демо-страницы (`examples/`)](#готовые-демо-страницы-examples)
- [Типы сервисов (`serviceMeta`)](#типы-сервисов-servicemeta)
- [Рекомендуемые типы сервисов](#рекомендуемые-типы-сервисов)
- [Настройки интерфейса (⚙)](#настройки-интерфейса-)
- [Поведение интерфейса](#поведение-интерфейса)
- [localStorage](#localstorage)
- [Разработка](#разработка)
- [Ограничения](#ограничения)
- [Лицензия](#лицензия)
## Возможности ## Возможности
- **Древовидная навигация** слева в стиле файлового менеджера - **Древовидная навигация** слева в стиле файлового менеджера
@ -15,6 +36,7 @@
- **Изменяемая ширина** бокового меню (drag) - **Изменяемая ширина** бокового меню (drag)
- **Сохранение состояния** в `localStorage` (настройки UI, раскрытие веток) - **Сохранение состояния** в `localStorage` (настройки UI, раскрытие веток)
- **Конфигурация через JSON** — локальный объект или загрузка по URL - **Конфигурация через JSON** — локальный объект или загрузка по URL
- **Гибкая иерархия**: полная вложенность, раздел без подраздела, плоский список
## Быстрый старт ## Быстрый старт
@ -31,12 +53,20 @@ npx serve .
``` ```
ecosystem/ ecosystem/
├── index.html # Разметка страницы ├── index.html # Разметка страницы
├── styles.css # Стили, темы, адаптив ├── styles.css # Стили, темы, адаптив
├── script.js # Логика, рендер, настройки ├── script.js # Логика, рендер, примеры структур
├── settings.json # Источник данных панели (URL или inline) ├── settings.json # Источник данных (URL или inline)
├── panel-data.json # Данные навигации (по умолчанию) ├── panel-data.json # Данные навигации (рабочий файл)
├── assets/ # SVG-иконки сервисов ├── 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 └── README.md
``` ```
@ -54,11 +84,11 @@ ecosystem/
} }
``` ```
Можно указать внешний адрес: Можно указать внешний адрес или любой из example-файлов:
```json ```json
{ {
"dataUrl": "https://example.com/ecosystem/panel-data.json" "dataUrl": "./panel-data-flat.example.json"
} }
``` ```
@ -68,7 +98,7 @@ ecosystem/
{ {
"dataUrl": "", "dataUrl": "",
"data": { "data": {
"title": "Панель экосистемы", "title": "Заголовок панели",
"sections": [] "sections": []
} }
} }
@ -78,76 +108,153 @@ ecosystem/
При ошибке загрузки или невалидном JSON на странице показывается экран с описанием проблемы. При ошибке загрузки или невалидном 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 ```json
{ {
"title": "Панель экосистемы", "title": "<заголовок панели>",
"sections": [ "sections": [
{ {
"id": "user", "id": "<slug-раздела>",
"number": "1", "number": "<опционально>",
"title": "Интерфейс пользователя", "title": "<название раздела>",
"description": "Описание раздела", "description": "<опционально>",
"items": [
{
"id": "<стабильный-ключ>",
"number": "<опционально>",
"title": "<название>",
"type": "<ключ из serviceMeta>",
"tags": ["<тег>"],
"url": "<ссылка>"
}
],
"groups": [ "groups": [
{ {
"id": "message-filtering", "id": "<slug-подраздела>",
"number": "1.1", "number": "<опционально>",
"title": "Управление фильтрацией", "title": "<название подраздела>",
"description": "Описание подраздела", "description": "<опционально>",
"items": [ "items": ["<элементы — та же форма>"]
{
"number": "1.1.1",
"title": "Редактирование правил",
"type": "rules",
"tags": ["правила", "фильтрация"],
"url": "https://rules.example.com"
}
]
} }
] ]
} }
], ],
"serviceMeta": {} "items": ["<альтернатива sections: плоский список>"],
"serviceMeta": {
"<type-key>": {
"label": "<подпись типа>",
"icon": "assets/<иконка>.svg"
}
}
} }
``` ```
Используй **либо** `sections`, **либо** корневой `items`. Внутри раздела — **либо** `groups`, **либо** `items`, **либо** оба.
### Поля данных ### Поля данных
| Уровень | Поля | Описание | | Уровень | Поля | Описание |
|---------|------|----------| |---------|------|----------|
| **Корень** | `title` | Заголовок панели (sidebar + `<title>`) | | **Корень** | `title` | Заголовок панели (sidebar + `<title>`) |
| | `sections` | Массив разделов | | | `sections` | Массив разделов (если нет корневого `items`) |
| | `serviceMeta` | Опционально: доп. типы сервисов | | | `items` | Плоский список без разделов |
| **section** | `id`, `number`, `title`, `description`, `groups` | Крупный раздел | | | `serviceMeta` | Дополнительные или переопределённые типы сервисов |
| **group** | `id`, `number`, `title`, `description`, `items` | Подраздел | | **section** | `id`, `title` | Обязательные идентификатор и название |
| **item** | `number`, `title`, `type`, `tags`, `url` | Кликабельный элемент | | | `number`, `description` | Опционально |
| | `groups`, `items` | Хотя бы одно из двух |
| **group** | `id`, `title`, `items` | Подраздел с элементами |
| | `number`, `description` | Опционально |
| **item** | `title`, `type` | Обязательные название и тип |
| | `url` | Ссылка; `"#"` — заглушка |
| | `tags` | Массив строк для поиска |
| | `id` | Стабильный ключ (DOM, фокус из дерева) |
| | `number` | Отображаемый номер |
- `type` — ключ из `serviceMeta` (определяет иконку и подпись типа) - `type` — ключ из встроенного `defaultServiceMeta` или из `serviceMeta` в JSON
- `tags` — используются в поиске и опционально в дереве - `id` элемента нужен, если нет `number` — иначе ключ генерируется автоматически
- `url` — ссылка перехода; `"#"` означает заглушку (клик не ведёт никуда)
### Примеры данных
Готовые демо-страницы — в папке [`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`) ### Типы сервисов (`serviceMeta`)
Базовые типы заданы в `script.js`: Базовые типы заданы в `script.js` (`defaultServiceMeta`):
`ceph`, `cloudbeaver`, `config`, `gitea`, `grafana`, `ipmi`, `kubernetes`, `ldap`, `messages`, `opensearch`, `postgresql`, `prometheus`, `rabbitmq`, `redmine`, `rules`, `xpath`, `xmpp` `ceph`, `cloudbeaver`, `config`, `gitea`, `grafana`, `ipmi`, `kubernetes`, `ldap`, `messages`, `opensearch`, `postgresql`, `prometheus`, `rabbitmq`, `redmine`, `rules`, `xpath`, `xmpp`
В `panel-data.json` можно добавить или переопределить типы: Переопределение или добавление типов в JSON:
```json ```json
{ {
"serviceMeta": { "serviceMeta": {
"my-service": { "my-type": {
"label": "My Service", "label": "Подпись",
"icon": "assets/config.svg" "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`): Доступны в 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. Обнови страницу 4. Обнови страницу
### Добавить новую иконку ### Добавить иконку и тип
1. Положи SVG в `assets/` 1. Положи SVG в `assets/` (или переиспользуй существующую)
2. Зарегистрируй тип в `serviceMeta` внутри `panel-data.json` или используй существующий `type` 2. Зарегистрируй тип в `serviceMeta` JSON или в `defaultServiceMeta` в `script.js`
### Внешний источник данных ### Внешний источник данных
Укажи URL в `settings.json`:
```json ```json
{ {
"dataUrl": "https://your-server/panel-data.json" "dataUrl": "https://your-server/panel-data.json"
} }
``` ```
Убедись, что сервер отдаёт корректный `Content-Type: application/json` и разрешает CORS, если домен отличается от панели. Убедись, что сервер отдаёт `Content-Type: application/json` и разрешает CORS при другом домене.
## Ограничения ## Ограничения

3
examples/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5502
}

73
examples/README.md Normal file
View File

@ -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/` (относительные пути уже настроены)

176
examples/flat/index.html Normal file
View File

@ -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>Пример: плоский список</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<div class="app-layout">
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<button
class="settings-button"
type="button"
aria-label="Открыть настройки внешнего вида"
aria-expanded="false"
data-settings-toggle
>
⚙
</button>
<p class="eyebrow" id="page-title">Пример: плоский список</p>
<label class="search-field">
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
autocomplete="off"
data-search-input
/>
</label>
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<div
class="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label="Изменить ширину бокового меню"
data-sidebar-resizer
></div>
<div class="settings-overlay" data-settings-overlay></div>
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
<p class="eyebrow">View settings</p>
<h2>Настройки</h2>
</div>
<button class="settings-close" type="button" aria-label="Закрыть настройки" data-settings-close>
×
</button>
</div>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
</div>
<div class="control-group view-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
</div>
<label class="toggle-row">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Теги рядом с элементом, без 2 колонок</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
<small>Компактный вид элементов папок</small>
</span>
<input type="checkbox" data-tree-two-column />
</label>
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
<small>Сворачивать ветки без совпадений</small>
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
<div class="control-group accent-group">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</div>
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
<h2>Не удалось загрузить панель</h2>
<p data-config-error-message></p>
</div>
</template>
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
<h2>Ничего не найдено</h2>
<p>Попробуй изменить запрос или искать по названию системы, тегу или роли.</p>
</div>
</template>
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
</body>
</html>

View File

@ -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"
}
]
}

View File

@ -0,0 +1,4 @@
{
"dataUrl": "./panel-data.json",
"data": null
}

176
examples/full/index.html Normal file
View File

@ -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="Демо панели: полная иерархия section → group → item."
/>
<title>Пример: section → group → item</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<div class="app-layout">
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<button
class="settings-button"
type="button"
aria-label="Открыть настройки внешнего вида"
aria-expanded="false"
data-settings-toggle
>
⚙
</button>
<p class="eyebrow" id="page-title">Пример: section → group → item</p>
<label class="search-field">
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
autocomplete="off"
data-search-input
/>
</label>
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<div
class="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label="Изменить ширину бокового меню"
data-sidebar-resizer
></div>
<div class="settings-overlay" data-settings-overlay></div>
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
<p class="eyebrow">View settings</p>
<h2>Настройки</h2>
</div>
<button class="settings-close" type="button" aria-label="Закрыть настройки" data-settings-close>
×
</button>
</div>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
</div>
<div class="control-group view-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
</div>
<label class="toggle-row">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Теги рядом с элементом, без 2 колонок</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
<small>Компактный вид элементов папок</small>
</span>
<input type="checkbox" data-tree-two-column />
</label>
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
<small>Сворачивать ветки без совпадений</small>
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
<div class="control-group accent-group">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</div>
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
<h2>Не удалось загрузить панель</h2>
<p data-config-error-message></p>
</div>
</template>
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
<h2>Ничего не найдено</h2>
<p>Попробуй изменить запрос или искать по названию системы, тегу или роли.</p>
</div>
</template>
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
</body>
</html>

View File

@ -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": "#"
}
]
}
]
}
]
}

View File

@ -0,0 +1,4 @@
{
"dataUrl": "./panel-data.json",
"data": null
}

137
examples/index.html Normal file
View File

@ -0,0 +1,137 @@
<!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>Примеры панели экосистемы</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="shared/styles.css" />
<style>
.examples-hub {
max-width: 960px;
margin: 0 auto;
padding: 48px 20px 64px;
}
.examples-hub h1 {
margin: 0 0 8px;
font-size: clamp(1.75rem, 4vw, 2.25rem);
}
.examples-hub > p {
margin: 0 0 28px;
color: var(--muted);
max-width: 56ch;
}
.examples-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.example-card {
display: block;
padding: 20px;
border: 1px solid var(--border);
border-radius: var(--radius-lg, 16px);
background: var(--surface);
text-decoration: none;
color: inherit;
transition: border-color 0.2s, transform 0.2s;
}
.example-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.example-card h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.example-card p {
margin: 0 0 12px;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.45;
}
.example-card code {
font-size: 0.82rem;
color: var(--accent);
}
.examples-note {
margin-top: 32px;
padding: 16px 18px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 88%, var(--accent) 12%);
color: var(--muted);
font-size: 0.92rem;
line-height: 1.5;
}
.examples-note strong {
color: var(--text);
}
</style>
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="examples-hub">
<p class="eyebrow">Ecosystem panel</p>
<h1>Примеры структур данных</h1>
<p>
Каждый пример — самодостаточная страница с локальными <code>settings.json</code> и
<code>panel-data.json</code>. Общие стили, скрипт и иконки лежат в <code>shared/</code>.
</p>
<div class="examples-grid">
<a class="example-card" href="./full/">
<h2>Полная иерархия</h2>
<p>Разделы, подразделы и элементы — классический формат.</p>
<code>section → group → item</code>
</a>
<a class="example-card" href="./section-items/">
<h2>Раздел → item</h2>
<p>Элементы сразу внутри раздела, без промежуточных подразделов.</p>
<code>section → item</code>
</a>
<a class="example-card" href="./flat/">
<h2>Плоский список</h2>
<p>Только карточки сервисов, без дерева разделов.</p>
<code>items[]</code>
</a>
<a class="example-card" href="./mixed/">
<h2>Смешанный раздел</h2>
<p>Закреплённые ссылки и подразделы в одном разделе.</p>
<code>items[] + groups[]</code>
</a>
</div>
<div class="examples-note">
<strong>Запуск:</strong> из папки <code>examples</code> выполни
<code>npx serve .</code> и открой <code>/</code> или конкретный пример, например
<code>/full/</code>. Можно также поднять сервер из корня репозитория —
тогда адрес будет <code>/examples/full/</code>.
</div>
</main>
</body>
</html>

176
examples/mixed/index.html Normal file
View File

@ -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>Пример: смешанный раздел</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<div class="app-layout">
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<button
class="settings-button"
type="button"
aria-label="Открыть настройки внешнего вида"
aria-expanded="false"
data-settings-toggle
>
⚙
</button>
<p class="eyebrow" id="page-title">Пример: смешанный раздел</p>
<label class="search-field">
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
autocomplete="off"
data-search-input
/>
</label>
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<div
class="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label="Изменить ширину бокового меню"
data-sidebar-resizer
></div>
<div class="settings-overlay" data-settings-overlay></div>
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
<p class="eyebrow">View settings</p>
<h2>Настройки</h2>
</div>
<button class="settings-close" type="button" aria-label="Закрыть настройки" data-settings-close>
×
</button>
</div>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
</div>
<div class="control-group view-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
</div>
<label class="toggle-row">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Теги рядом с элементом, без 2 колонок</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
<small>Компактный вид элементов папок</small>
</span>
<input type="checkbox" data-tree-two-column />
</label>
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
<small>Сворачивать ветки без совпадений</small>
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
<div class="control-group accent-group">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</div>
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
<h2>Не удалось загрузить панель</h2>
<p data-config-error-message></p>
</div>
</template>
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
<h2>Ничего не найдено</h2>
<p>Попробуй изменить запрос или искать по названию системы, тегу или роли.</p>
</div>
</template>
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
</body>
</html>

View File

@ -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"
}
]
}
]
}
]
}

View File

@ -0,0 +1,4 @@
{
"dataUrl": "./panel-data.json",
"data": null
}

View File

@ -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="Демо панели: раздел с элементами без подразделов (section → item)."
/>
<title>Пример: section → item</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="../shared/styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<div class="app-layout">
<aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content">
<button
class="settings-button"
type="button"
aria-label="Открыть настройки внешнего вида"
aria-expanded="false"
data-settings-toggle
>
⚙
</button>
<p class="eyebrow" id="page-title">Пример: section → item</p>
<label class="search-field">
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
autocomplete="off"
data-search-input
/>
</label>
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div>
</aside>
<div
class="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label="Изменить ширину бокового меню"
data-sidebar-resizer
></div>
<div class="settings-overlay" data-settings-overlay></div>
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
<p class="eyebrow">View settings</p>
<h2>Настройки</h2>
</div>
<button class="settings-close" type="button" aria-label="Закрыть настройки" data-settings-close>
×
</button>
</div>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">Темная</button>
<button class="segmented" type="button" data-theme="light">Светлая</button>
</div>
<div class="control-group view-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">Плитки</button>
<button class="segmented" type="button" data-view="list">Список</button>
<button class="segmented" type="button" data-view="compact-cards">Компакт плитки</button>
<button class="segmented" type="button" data-view="compact-list">Компакт список</button>
</div>
<label class="toggle-row">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Теги рядом с элементом, без 2 колонок</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<label class="toggle-row">
<span>
<strong>2 колонки в дереве</strong>
<small>Компактный вид элементов папок</small>
</span>
<input type="checkbox" data-tree-two-column />
</label>
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
<small>Сворачивать ветки без совпадений</small>
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
<div class="control-group accent-group">
<span>Акцент</span>
<button class="accent-swatch is-active" type="button" data-accent="violet" aria-label="Фиолетовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="cyan" aria-label="Голубой акцент"></button>
<button class="accent-swatch" type="button" data-accent="lime" aria-label="Лаймовый акцент"></button>
<button class="accent-swatch" type="button" data-accent="amber" aria-label="Янтарный акцент"></button>
<button class="accent-swatch accent-swatch-custom" type="button" data-accent="custom" aria-label="Свой градиент" title="Свой градиент">
<span class="accent-swatch-label">Свой</span>
</button>
</div>
<div class="accent-custom-panel" data-accent-custom-panel hidden>
<p class="accent-custom-title">Свой градиент</p>
<label class="accent-custom-row">
<span>Угол</span>
<input type="range" min="0" max="360" step="1" value="135" data-custom-accent-angle />
<output data-custom-accent-angle-value>135°</output>
</label>
<label class="accent-custom-row">
<span>Цвет от</span>
<input type="color" value="#8b5cf6" data-custom-accent-from />
</label>
<label class="accent-custom-row">
<span>Цвет до</span>
<input type="color" value="#22d3ee" data-custom-accent-to />
</label>
<div class="accent-custom-preview" data-custom-accent-preview aria-hidden="true"></div>
</div>
</div>
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<template id="config-error-template">
<div class="empty-state config-error-state">
<img src="../shared/assets/config.svg" alt="" />
<h2>Не удалось загрузить панель</h2>
<p data-config-error-message></p>
</div>
</template>
<template id="empty-template">
<div class="empty-state">
<img src="../shared/assets/search.svg" alt="" />
<h2>Ничего не найдено</h2>
<p>Попробуй изменить запрос или искать по названию системы, тегу или роли.</p>
</div>
</template>
</div>
</main>
<script
src="../shared/script.js"
data-ecosystem-panel
data-asset-base="../shared/"
defer
></script>
</body>
</html>

View File

@ -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": "#"
}
]
}
]
}

View File

@ -0,0 +1,4 @@
{
"dataUrl": "./panel-data.json",
"data": null
}

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="80" cy="80" r="40" fill="white" fill-opacity=".16"/><circle cx="80" cy="80" r="16" fill="white"/><circle cx="45" cy="65" r="15" fill="white"/><circle cx="115" cy="65" r="15" fill="white"/><circle cx="62" cy="112" r="15" fill="white"/><circle cx="98" cy="112" r="15" fill="white"/><path d="M57 71l16 7M103 71l-16 7M70 104l7-13M90 91l7 13" stroke="#BE123C" stroke-width="6" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FB7185"/><stop offset="1" stop-color="#BE123C"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="80" cy="80" r="34" fill="white"/><path d="M80 44v18M80 98v18M44 80h18M98 80h18M55 55l13 13M92 92l13 13M105 55L92 68M68 92l-13 13" stroke="#4338CA" stroke-width="8" stroke-linecap="round"/><circle cx="80" cy="80" r="12" fill="#4338CA"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#818CF8"/><stop offset="1" stop-color="#4338CA"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><ellipse cx="80" cy="50" rx="42" ry="18" fill="white"/><path d="M38 50v60c0 10 19 18 42 18s42-8 42-18V50" fill="white" fill-opacity=".86"/><path d="M38 78c0 10 19 18 42 18s42-8 42-18M38 104c0 10 19 18 42 18s42-8 42-18" stroke="#0F766E" stroke-width="7"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#2DD4BF"/><stop offset="1" stop-color="#0F766E"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 569 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M49 60h53c16 0 28 12 28 28s-12 28-28 28H73c-22 0-40-18-40-40 0-9 7-16 16-16z" fill="white"/><circle cx="67" cy="86" r="7" fill="#4D7C0F"/><circle cx="101" cy="86" r="7" fill="#4D7C0F"/><path d="M75 102c7 5 16 5 24 0" stroke="#4D7C0F" stroke-width="6" stroke-linecap="round"/><path d="M103 57l13-13M113 45h14v14" stroke="#FEF3C7" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#A3E635"/><stop offset="1" stop-color="#65A30D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 718 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M82 124c-27 0-49-18-49-43 0-22 18-40 41-40 18 0 33 12 33 29 0 14-11 25-25 25-11 0-20-8-20-18 0-8 7-15 16-15 6 0 11 4 11 10" stroke="white" stroke-width="10" stroke-linecap="round"/><circle cx="116" cy="43" r="13" fill="#FDE68A"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#F97316"/><stop offset="1" stop-color="#7C2D12"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M80 30l45 26v52l-45 26-45-26V56l45-26z" fill="white" fill-opacity=".18" stroke="white" stroke-width="8" stroke-linejoin="round"/><circle cx="80" cy="82" r="22" fill="white"/><path d="M80 48v68M46 82h68M56 58l48 48M104 58l-48 48" stroke="#2563EB" stroke-width="7" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#60A5FA"/><stop offset="1" stop-color="#1D4ED8"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="80" cy="56" r="22" fill="white"/><path d="M42 122c6-28 20-42 38-42s32 14 38 42" fill="white"/><circle cx="43" cy="80" r="13" fill="white" fill-opacity=".82"/><circle cx="117" cy="80" r="13" fill="white" fill-opacity=".82"/><path d="M56 86h48" stroke="#047857" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#6EE7B7"/><stop offset="1" stop-color="#047857"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 628 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M36 48h88c8 0 14 6 14 14v38c0 8-6 14-14 14H78l-28 18v-18H36c-8 0-14-6-14-14V62c0-8 6-14 14-14z" fill="white"/><path d="M50 72h60M50 92h42" stroke="#0891B2" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#67E8F9"/><stop offset="1" stop-color="#0891B2"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="70" cy="70" r="30" stroke="white" stroke-width="12"/><path d="M93 93l27 27" stroke="white" stroke-width="13" stroke-linecap="round"/><path d="M49 69h42M58 53h24M58 85h24" stroke="#0369A1" stroke-width="7" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#38BDF8"/><stop offset="1" stop-color="#0369A1"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M80 34c22 24 34 44 34 62 0 20-15 34-34 34s-34-14-34-34c0-18 12-38 34-62z" fill="white"/><path d="M66 82c12 8 16 8 28 0M65 103h30M80 54v32" stroke="#DC2626" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FCA5A5"/><stop offset="1" stop-color="#DC2626"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M42 112V48c0-8 6-14 14-14s14 6 14 14v28h20V48c0-8 6-14 14-14s14 6 14 14v64H42z" fill="white"/><rect x="64" y="88" width="18" height="18" rx="4" fill="#EA580C"/><rect x="94" y="88" width="18" height="18" rx="4" fill="#EA580C"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FDBA74"/><stop offset="1" stop-color="#EA580C"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><rect x="38" y="38" width="84" height="84" rx="18" fill="white"/><path d="M58 66h44M58 86h30M58 106h44" stroke="#B91C1C" stroke-width="8" stroke-linecap="round"/><circle cx="104" cy="86" r="10" fill="#FCA5A5"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#F87171"/><stop offset="1" stop-color="#B91C1C"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 525 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><rect x="40" y="34" width="80" height="92" rx="16" fill="white"/><path d="M60 62h42M60 82h42M60 102h24" stroke="#6D28D9" stroke-width="8" stroke-linecap="round"/><path d="M47 62l6 6 12-15M47 102l6 6 12-15" stroke="#22C55E" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#C4B5FD"/><stop offset="1" stop-color="#6D28D9"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="70" cy="70" r="30" stroke="white" stroke-width="12"/><path d="M94 94l28 28" stroke="white" stroke-width="13" stroke-linecap="round"/><path d="M60 70h20M70 60v20" stroke="#6D28D9" stroke-width="7" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#C4B5FD"/><stop offset="1" stop-color="#6D28D9"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><rect x="42" y="32" width="76" height="96" rx="14" fill="white"/><path d="M56 55h48M56 80h48M56 105h48" stroke="#334155" stroke-width="8" stroke-linecap="round"/><circle cx="103" cy="55" r="5" fill="#22C55E"/><circle cx="103" cy="80" r="5" fill="#FACC15"/><circle cx="103" cy="105" r="5" fill="#EF4444"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#94A3B8"/><stop offset="1" stop-color="#334155"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M46 44c10 38 21 61 34 82 13-21 24-44 34-82" stroke="white" stroke-width="12" stroke-linecap="round"/><path d="M46 44c24 29 44 45 68 60M114 44c-24 29-44 45-68 60" stroke="white" stroke-opacity=".62" stroke-width="8" stroke-linecap="round"/><circle cx="80" cy="84" r="10" fill="#15803D"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#86EFAC"/><stop offset="1" stop-color="#15803D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M62 50L34 80l28 30M98 50l28 30-28 30" stroke="white" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><path d="M87 42L69 118" stroke="#FDE68A" stroke-width="10" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FBBF24"/><stop offset="1" stop-color="#B45309"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 526 B

1139
examples/shared/script.js Normal file

File diff suppressed because it is too large Load Diff

1542
examples/shared/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -247,6 +247,6 @@
</div> </div>
</main> </main>
<script src="script.js" defer></script> <script src="script.js" data-ecosystem-panel defer></script>
</body> </body>
</html> </html>

View File

@ -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"
}
]
}

View File

@ -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"
}
]
}
]
}
]
}

View File

@ -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"
}
]
}
]
}
]
}

View File

@ -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"
}
]
}
]
}

176
script.js
View File

@ -15,7 +15,18 @@ const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
// settings.json задает источник данных панели: // settings.json задает источник данных панели:
// - dataUrl: URL или путь к JSON (fetch), например "./panel-data.json" // - dataUrl: URL или путь к JSON (fetch), например "./panel-data.json"
// - data: inline-объект с той же структурой, если dataUrl пустой // - 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 ecosystemSections = [];
let panelTitle = "Панель экосистемы"; let panelTitle = "Панель экосистемы";
@ -317,14 +328,39 @@ async function fetchJson(url) {
} }
function normalizePanelData(raw) { 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) { // Корневой items[] без sections — плоский список карточек.
throw new Error("В данных панели нет 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 { return {
title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "Панель экосистемы", title: panelTitle,
sections, sections,
serviceMeta: { serviceMeta: {
...defaultServiceMeta, ...defaultServiceMeta,
@ -338,12 +374,23 @@ function normalizeSection(section) {
throw new Error("Каждый section должен содержать id и 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 { return {
id: String(section.id), id: String(section.id),
number: String(section.number ?? ""), number: String(section.number ?? ""),
title: String(section.title), title: String(section.title),
description: String(section.description ?? ""), 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) { if (!item?.title || !item?.type) {
throw new Error("Каждый item должен содержать title и 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 { return {
itemKey,
number: String(item.number ?? ""), number: String(item.number ?? ""),
title: String(item.title), title: String(item.title),
type: String(item.type), 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) { function showConfigError(error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
navTree.replaceChildren(); navTree.replaceChildren();
@ -396,7 +456,7 @@ function showConfigError(error) {
// ============================================================================ // ============================================================================
// Упрощает запись item-объектов при ручной правке JS (legacy helper). // Упрощает запись item-объектов при ручной правке JS (legacy helper).
function createItem(number, title, type, tags = [], url = "#") { 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; let visibleItems = 0;
ecosystemSections.forEach((section) => { 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); const sectionElement = createSectionElement(section, forceOpen, query);
let visibleGroups = 0;
let sectionVisibleItems = 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) => { section.groups.forEach((group) => {
const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query)); const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query));
@ -425,12 +503,11 @@ function renderCatalog() {
return; return;
} }
visibleGroups += 1;
sectionVisibleItems += filteredItems.length; 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; sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems;
fragment.append(sectionElement); fragment.append(sectionElement);
visibleItems += sectionVisibleItems; visibleItems += sectionVisibleItems;
@ -445,7 +522,13 @@ function renderCatalog() {
} else { } else {
catalog.append(emptyTemplate.content.cloneNode(true)); 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 для раздела контента. // Создает корневой details для раздела контента.
@ -528,7 +611,7 @@ function createCardElement(item, query = "") {
link.innerHTML = ` link.innerHTML = `
<span class="shine"></span> <span class="shine"></span>
<img src="${meta.icon}" alt="" loading="lazy" /> <img src="${resolveAssetPath(meta.icon)}" alt="" loading="lazy" />
<div class="card-content"> <div class="card-content">
<p class="item-number">${highlightText(item.number, query)}</p> <p class="item-number">${highlightText(item.number, query)}</p>
<h4>${highlightText(item.title, query)}</h4> <h4>${highlightText(item.title, query)}</h4>
@ -559,7 +642,8 @@ function matchesQuery(item, group, section, query) {
item.type, item.type,
meta.label, meta.label,
item.tags.join(" "), item.tags.join(" "),
group.title, group?.title ?? "",
group?.description ?? "",
section.title, section.title,
].join(" "), ].join(" "),
); );
@ -567,6 +651,18 @@ function matchesQuery(item, group, section, query) {
return haystack.includes(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) { function groupMatchesQuery(group, section, query) {
@ -600,8 +696,18 @@ function renderNavigationTree() {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
ecosystemSections.forEach((section) => { ecosystemSections.forEach((section) => {
const sectionOwnMatch = matchesText([section.number, section.title, section.description], query); if (section.flat && !section.groups.length) {
const sectionHasMatches = sectionOwnMatch || section.groups.some((group) => groupMatchesQuery(group, section, query)); 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"); const sectionNode = document.createElement("details");
sectionNode.className = "tree-section"; sectionNode.className = "tree-section";
sectionNode.dataset.collapseKey = getTreeSectionId(section); sectionNode.dataset.collapseKey = getTreeSectionId(section);
@ -616,6 +722,11 @@ function renderNavigationTree() {
`; `;
sectionNode.append(sectionSummary); 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) => { section.groups.forEach((group) => {
const groupHasMatches = groupMatchesQuery(group, section, query); const groupHasMatches = groupMatchesQuery(group, section, query);
const groupNode = document.createElement("details"); const groupNode = document.createElement("details");
@ -631,20 +742,7 @@ function renderNavigationTree() {
</button> </button>
`; `;
groupNode.append(groupSummary); groupNode.append(groupSummary);
groupNode.append(createTreeItemList(group.items.filter((item) => matchesQuery(item, group, section, query)), query));
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 = `<span>${highlightText(item.title, query)}</span><small>${highlightText(item.tags.join(", "), query)}</small>`;
itemList.append(itemButton);
});
groupNode.append(itemList);
sectionNode.append(groupNode); sectionNode.append(groupNode);
}); });
@ -654,6 +752,22 @@ function renderNavigationTree() {
navTree.replaceChildren(fragment); 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 = `<span>${highlightText(item.title, query)}</span><small>${highlightText(item.tags.join(", "), query)}</small>`;
itemList.append(itemButton);
});
return itemList;
}
// ============================================================================ // ============================================================================
// Focusing from tree to content // Focusing from tree to content
// ============================================================================ // ============================================================================
@ -967,7 +1081,7 @@ function getTreeGroupId(group) {
} }
function getItemId(item) { function getItemId(item) {
return `item-${item.number.replaceAll(".", "-")}`; return `item-${String(item.itemKey).replaceAll(".", "-")}`;
} }
// Ограничивает число диапазоном. // Ограничивает число диапазоном.

View File

@ -1016,6 +1016,23 @@ html[data-theme="light"] .accent-swatch-custom {
padding: 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 { .catalog-group {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 18px; border-radius: 18px;