diff --git a/.gitignore b/.gitignore
index e69de29..c40d08b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+dist/
+.vite/
+.DS_Store
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+*.tsbuildinfo
diff --git a/design.md b/design.md
new file mode 100644
index 0000000..ad58553
--- /dev/null
+++ b/design.md
@@ -0,0 +1,224 @@
+# NODE.DC Launcher Design
+
+Этот документ фиксирует рабочий дизайн-канон именно для `NODE.DC Launcher`. Источники: `doc/base/nodedc_launcher_tz_frontend_mvp.md`, `doc/base/BASE_THINK.md`, `doc/base/HDESIGN-CODE.md` и визуальный референс `doc/base/VISUALREF.png`.
+
+## 1. Продуктовая рамка
+
+Launcher - отдельный control plane экосистемы NODE.DC. Он не заменяет NodeDC, Task Manager, Plane, 1C Assistant или будущие сервисы. Его интерфейс показывает доступные приложения, объясняет доступ и даёт администраторам управлять клиентами, участниками, группами, каталогом сервисов, инвайтами и синхронизацией.
+
+На текущем фронтовом MVP реальный Authentik не подключается. Но интерфейс и данные должны быть собраны так, чтобы позже заменить mock-профиль на OIDC claims без переписывания экранов.
+
+## 2. Визуальная цель
+
+Launcher должен ощущаться как витрина приложений, а не как таблица с плитками. Пользовательский экран строится вокруг трёх зон:
+
+- `Service Stage`: большая визуальная сцена выбранного сервиса.
+- `Service Detail`: glass-карточка с названием, описанием, статусом и действием.
+- `Service Rail`: нижняя лента доступных сервисов.
+
+Референс `VISUALREF.png` даёт направление: тёмная монохромная сцена, слои полупрозрачного glass, мягкий blur, крупная фокусная карточка, плавающая панель управления и нижняя лента элементов. Мы не копируем чужой UI буквально, а переносим композицию, ощущение глубины, верхние толстые pill-кнопки и плавающие окна.
+
+## 2.1. Канон Task Manager, перенесённый в Launcher
+
+Launcher должен наследовать уже сделанный NODE.DC Task Manager, а не жить отдельной визуальной веткой.
+
+Из Task Manager переносим:
+
+- логотип `NODE.DC` из `demo-assets/logo.svg` / `apps/admin/app/assets/logos/nodedc-logo.svg`;
+- admin header: лого слева, центральная группа `app-dot + nav-track`, user control справа;
+- top nav items: крупные круглые pill-кнопки без внешней рамки, высота и паддинги как в Task Manager;
+- active top item: светлая filled-плашка с тёмным текстом;
+- user button: прозрачная зона + круглый avatar/control справа;
+- modal/window shell: `nodedc-glass-modal`-логика, matte black glass, radius `1.75rem`, close `X` в правом верхнем углу;
+- settings shell: left navigation + content area, карточки `nodedc-settings-card`, поля `nodedc-settings-input`, кнопки `nodedc-settings-save-button` / `nodedc-settings-secondary-button`;
+- dropdown shell: `nodedc-dropdown-surface`, portal/floating layer, без inline popup внутри карточек.
+
+Нельзя делать Launcher-шапку, кнопки или админ-окна локально "на глаз", если в Task Manager уже есть паттерн.
+
+В верхней шапке запрещены технические runtime/debug-панели, даты MVP, mock labels и любые отладочные карточки. Dev-переключатели ролей допустимы только как часть пользовательского nav-track и должны выглядеть как production controls.
+
+## 3. Роли в дизайне
+
+Разный доступ должен быть виден в самом UI, даже без реальной авторизации:
+
+- `root_admin`: видит полный каталог, скрытые/отключённые сервисы, всех клиентов и системную диагностику.
+- `client_owner` / `client_admin`: видит только свой клиент, его участников, группы, инвайты и доступы к уже подключённым сервисам.
+- `member`: видит только витрину доступных ему сервисов и профиль.
+
+В MVP допустим dev-переключатель профиля, но он должен моделировать будущие роли, а не быть случайным режимом интерфейса.
+
+## 4. Цвета и токены
+
+Используем CSS variables, совместимые с glass-canon NODE.DC:
+
+```css
+:root {
+ --nodedc-accent-rgb: 195 255 102;
+ --nodedc-card-passive-rgb: 42 43 46;
+ --nodedc-card-active-rgb: 195 255 102;
+ --nodedc-on-accent-rgb: 11 17 23;
+ --launcher-radius-modal: 1.75rem;
+ --launcher-radius-card: 1.35rem;
+ --launcher-radius-control: 1.25rem;
+ --launcher-radius-circle: 999px;
+}
+```
+
+Primary CTA на светлом акценте всегда получает тёмный текст. Белый текст на светло-зелёной заливке запрещён.
+
+Фон приложения моноцветный: базовый `#050506`. Нельзя добавлять цветные декоративные фоны, орбы, bokeh, радужные подложки или самостоятельные fullscreen-градиенты. Цвет сервиса может жить внутри media-card/stage preview и в акцентных controls, но не должен перекрашивать весь экран приложения.
+
+## 5. Surface и glass
+
+Базовый surface:
+
+```css
+.launcher-glass-surface {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%),
+ rgba(8, 8, 11, 0.78);
+ backdrop-filter: blur(28px);
+ -webkit-backdrop-filter: blur(28px);
+ border: 0;
+ box-shadow:
+ 0 24px 64px rgba(0, 0, 0, 0.42),
+ inset 0 1px 0 rgba(255, 255, 255, 0.025);
+ border-radius: var(--launcher-radius-card);
+}
+```
+
+Popup, admin overlay, rail-карточки, формы и таблицы используют этот канон. Светлые прямоугольные окна внутри тёмного экрана не используются.
+
+## 6. Радиусы
+
+- Модалки и большой admin overlay: `1.75rem`.
+- Стандартные карточки и panels: `1.35rem`.
+- Инпуты, селекты, CTA, chips: `1.25rem`.
+- Icon actions и status anchors: круг или `999px`.
+
+## 7. Кнопки и controls
+
+- Primary: акцентная заливка, тёмный текст, без outline.
+- Secondary: тёмный glass surface, мягкий hover.
+- Danger: мягкая danger-заливка без кислотных рамок.
+- Icon actions: круглые, с tooltip/title, без квадратной active-плашки.
+- Binary settings: круглый checker, а не стандартный квадратный checkbox.
+
+Внешние outline запрещены вообще. Не использовать `border` как контур кнопки/карточки/окна. Не использовать синий browser outline. Focus-состояние делается через изменение surface/hover-state, а не через рамку.
+
+Верхние кнопки:
+
+- только pill/circle geometry;
+- `min-height` около `2.75rem`;
+- нормальный horizontal padding, текст не липнет к радиусу;
+- active item filled, а не outlined;
+- рядом стоящие controls должны иметь одну высоту.
+
+## 8. Dropdown / popup
+
+Все dropdown, которые открываются внутри карточек, таблиц, scroll-контейнеров, sticky header или detail panel, должны рендериться через portal. Inline popup внутри ограниченного контейнера считается дефектом, потому что создаёт clipping и визуальное налезание.
+
+Для текущего MVP, где dropdown можно заменить компактным selector/segmented control без clipping-риска, portal можно не вводить насильно. Но reusable `PortalDropdown` остаётся обязательной точкой расширения.
+
+## 9. Пользовательская витрина
+
+Top bar:
+
+- логотип NODE.DC Launcher;
+- активный клиент;
+- переключатель mock-профиля;
+- профиль пользователя;
+- кнопка администрирования только при праве `canOpenAdmin`.
+
+Service Stage:
+
+- всегда держит один большой video/stage-контейнер под будущий фоновой ролик;
+- при выборе сервиса поверх video/stage появляются два блока: image-card выбранного сервиса и glass-description;
+- glass-description обязан использовать `backdrop-filter` / `-webkit-backdrop-filter`, чтобы реально блюрить контент за плашкой;
+- повторный клик по уже выбранной плитке закрывает image-card и glass-description, video/stage остаётся на месте;
+- имеет fallback на абстрактный service preview;
+- меняется при выборе сервиса;
+- не должен выглядеть как marketing hero.
+
+Service Rail:
+
+- нижняя единая dock-лента сервисов;
+- сервисы отображаются квадратными модульными плитками, а не широкими разрозненными карточками;
+- плитка содержит icon/preview, название и статус;
+- active state использует `--nodedc-card-active-rgb`;
+- maintenance видна, но action disabled;
+- hidden/disabled не видны обычному пользователю.
+
+## 10. Admin overlay
+
+Admin overlay - floating glass-window поверх Launcher, а не отдельная публичная страница и не full-screen page.
+
+Правила:
+
+- фон Launcher остаётся видимым под затемнением/blur;
+- выбранный сервис, клиент и профиль в Launcher не сбрасываются при открытии/закрытии;
+- окно закрывается круглой кнопкой `X` в правом верхнем углу;
+- клик по затемнённому фону может закрыть окно, если нет незавершённой формы;
+- внутренний layout повторяет settings/admin паттерн Task Manager: left nav + content;
+- окно имеет max-width/max-height и не занимает весь viewport на desktop.
+
+Root admin разделы:
+
+- Обзор;
+- Клиенты;
+- Участники;
+- Группы;
+- Каталог сервисов;
+- Доступы;
+- Инвайты;
+- Синхронизация;
+- Аудит.
+
+Client admin разделы:
+
+- Обзор;
+- Участники;
+- Группы;
+- Доступы к сервисам;
+- Инвайты;
+- Профиль компании.
+
+Client admin не видит других клиентов и не может редактировать глобальный каталог сервисов.
+
+## 11. Access UI
+
+Матрица доступов должна показывать итог, а не только сырые grants:
+
+- есть ли доступ;
+- можно ли открыть сервис;
+- источник доступа: клиент, группа, пользователь, исключение;
+- роль в приложении;
+- объяснение причины.
+
+Deny exception перекрывает любые client/group/user grants. Это должно быть видно в ячейке и explanation panel.
+
+## 12. Тексты
+
+Пользовательский UI на русском. В production-видимых элементах не оставлять `Created at`, `Updated at`, `Workspace`, `Project`, `Label`, если экран уже русифицирован.
+
+## 13. MVP-границы
+
+Сейчас делаем фронтовую механику:
+
+- mock API;
+- mock Authentik claims;
+- переключение профилей для проверки ролей;
+- вычисление доступов на фронте;
+- интерфейс витрины;
+- admin overlay;
+- матрицу доступов;
+- mock invite/sync/audit.
+
+Не делаем сейчас:
+
+- реальный Authentik login;
+- production SSO;
+- email-инвайты;
+- backend CRUD;
+- синхронизацию с Plane/NodeDC;
+- управление проектами, workflow, задачами или runtime-правами внутри подключённых сервисов.
diff --git a/doc/base/1.gif b/doc/base/1.gif
new file mode 100644
index 0000000..a3f8c35
Binary files /dev/null and b/doc/base/1.gif differ
diff --git a/doc/base/HDESIGN-CODE.md b/doc/base/HDESIGN-CODE.md
new file mode 100644
index 0000000..ffe5677
--- /dev/null
+++ b/doc/base/HDESIGN-CODE.md
@@ -0,0 +1,424 @@
+# HDESIGN CODE
+
+Документ фиксирует канон интерфейса NODE.DC, чтобы не обсуждать одни и те же правила повторно.
+
+Связанные документы:
+- архитектурный регламент dropdown-окон: [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md)
+- экранный аудит и backlog миграции: [HUI-CANON-AUDIT.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HUI-CANON-AUDIT.md)
+- активный техдолг по незавершенной миграции dropdown-layer: [plane-src/docs/technical-debts/dropdown-standardization-debt.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/docs/technical-debts/dropdown-standardization-debt.md)
+
+## Источник цветов
+- Основной runtime-конфиг цветов: [design.config.json](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/design.config.json)
+- Рабочая web-копия: [plane-src/apps/web/design.config.json](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/web/design.config.json)
+- В рантайме используются CSS variables:
+ - `--nodedc-accent-rgb`
+ - `--nodedc-card-passive-rgb`
+ - `--nodedc-card-active-rgb`
+
+## Цветовые правила
+- `accent_rgb`: акцентный цвет интерфейса.
+- `passive_card_rgb`: пассивные карточки.
+- `active_card_rgb`: активные карточки и primary CTA в зелёной теме.
+- Primary/active элементы используют акцентный или `active_card_rgb`.
+- Secondary элементы не должны иметь ярких outline и цветных рамок без явной причины.
+
+## Радиусы
+- Главные модалки и большие surface-контейнеры: `1.75rem`
+- Стандартные glass-карточки и settings-карточки: `1.35rem`
+- Поля ввода, селекты, secondary/primary кнопки, chip-кнопки: `1.25rem`
+- Малые круглые action-кнопки: `999px` или полный круг при квадратной коробке
+
+## Outline и рамки
+- Внешние outline у контролов запрещены.
+- Синий browser outline должен быть снят и заменён на нормальный hover/focus surface.
+- Если нужен контур, он должен быть частью дизайна:
+ - мягкий glass border
+ - акцентный border для drag/drop или active-state
+- Красные технические outline, debug-рамки и случайные browser-box shadow запрещены.
+
+## Glass и фон
+- Popup, dropdown, modal, sidebar overlays и settings-карточки используют matte black glass.
+- База:
+ - тёмный полупрозрачный фон
+ - `backdrop-filter: blur(...)`
+ - мягкая стеклянная граница
+- Popup не должны выглядеть просто прозрачными. Если blur не читается, проблема в слое рендера, а не в одном `rgba`.
+
+## Кнопки
+- Все кнопки без жёсткого outline.
+- Текстовые кнопки в модалках не сжимают текст:
+ - минимальный горизонтальный отступ от текста до края кнопки: `10px`
+ - для CTA предпочтительно использовать общий padding `1.25rem` или больше
+ - текст не должен визуально прилипать к радиусу кнопки
+- Primary button:
+ - фон: акцентный или `active_card_rgb`
+ - текст: определяется автоматически по контрасту заливки
+ - если заливка светлая, текст тёмный
+ - если заливка тёмная, текст светлый
+ - hover: более светлая версия того же цвета
+ - правило распространяется на все filled CTA:
+ - `Добавить`
+ - `Сохранить`
+ - `Обновить`
+ - `Принять`
+ - `Добавить запрос`
+ - любые акцентные toolbar-кнопки
+ - это правило обязательно и для `Внешних контуров`: `Добавить запрос` не может иметь светлый текст на светлом фоне
+- Save/update button:
+ - если это CTA на `accent_rgb` или `active_card_rgb`, текст не задаётся вручную белым или чёрным
+ - используется системное контрастное значение
+ - hover осветляет текущий тон, а не уходит в синий
+- Secondary button:
+ - тёмный glass фон
+ - без border-outline
+ - hover немного светлее базового surface
+- Danger button:
+ - без кислотно-красных рамок
+ - мягкий danger surface
+
+## Поля и селекты
+- Все поля ввода, textarea, select, chip-select:
+ - скруглённые
+ - без внешних outline
+ - glass background
+ - единая вертикальная высота для одного класса контролов
+- Placeholder и label должны быть читаемы и не прилипать к краям.
+
+## Чекеры
+- Для бинарных настроек в glass-интерфейсе используется круглый checker в стиле фильтров отображения.
+- Активное состояние:
+ - круг залит `rgb(var(--nodedc-accent-rgb))`
+ - внутри маленькая точка `rgb(var(--nodedc-on-accent-rgb))`
+- Неактивное состояние:
+ - круг на мягком `white/10`
+ - без внешнего outline и без синей browser-рамки
+- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
+- В деталях задачи структурные блоки создаются из меню `Добавить подэлемент` прямо в карточке, без отдельной модалки:
+ - порядок меню: `Создать текстовый блок`, `Создать чекер`, `Создать новую подзадачу`, `Добавить существующую подзадачу`
+ - текстовый блок содержит два поля: необязательный заголовок и текст
+ - чекер отображается без внешней подложки и без заголовка: только строки с круглым checker-якорем и plus-зона добавления строки
+ - первые 10 строк чекера видны сразу, дальше включается внутренний скролл списка
+ - у каждого структурного блока справа есть меню `...` с удалением блока
+ - блок хранится в штатном JSON-поле задачи `detail_layout`, а не в `description_html`: описание проходит HTML-sanitizer и не должно нести layout-состояние
+ - `detail_layout` является частью самой задачи, поэтому кастомные поля мультиплеерны и восстанавливаются после закрытия/повторного открытия карточки
+
+## Toolbar и верхние панели
+- Элементы верхней панели центрируются по одной горизонтальной оси.
+- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
+- Кнопки `Отображение`, `Аналитика`, `Добавить рабочий элемент`:
+ - одинаковая высота
+ - каноничные радиусы
+ - нормальные горизонтальные paddings, чтобы текст не лип к краям
+
+## Карточки
+- Внутренние карточки строятся по симметричным верхним и нижним padding.
+- Верхняя ось:
+ - аватар
+ - имя
+ - вторичная строка
+ - action-circle справа
+ должны сидеть на согласованной геометрии
+- Нижняя ось:
+ - assignee bubbles
+ - дата
+ должны быть симметричны верхней
+- Для списков карточек `Внешних контуров` используется тот же вертикальный ритм, что и у `Внутреннего контура`:
+ - контейнер списка не плотнее `space-y-3`
+ - нельзя лепить карточки вплотную друг к другу
+
+## Dropdown и popup
+- Все dropdown/popup приводятся к единому matte glass канону.
+- Подробный архитектурный и поведенческий регламент dropdown-окон вынесен в [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md).
+- Запрещены:
+ - квадратные active-box вокруг круглых кнопок
+ - жёсткие border-outline
+ - светлый фон, если основной экран тёмный
+- Search shell внутри popup должен использовать тот же стиль, что и сам popup.
+- Filled CTA внутри popup и модалок подчиняются тому же правилу:
+ - светлый акцентный фон
+ - контрастный текст от реальной яркости фона
+ - hover только в более светлый тон того же цвета
+
+### Portal правило
+- Если selector/dropdown открывается внутри:
+ - scroll-контейнера
+ - detail-pane
+ - карточки
+ - properties section
+ - sidebar
+ - sticky header
+ он не должен рендериться inline.
+- Такой popup обязан рендериться на верхнем слое через `portal` (`document.body` или эквивалент).
+- Inline popup в ограниченном контейнере считается дефектом, потому что даёт:
+ - клиппинг
+ - налезание на соседние блоки
+ - старую “врезанную” верстку
+- Для `Свойств` в `Внешних контурах` dropdown по умолчанию открывается вверх:
+ - `placement="top-start"`
+ - причина: блок находится близко к `Активности`, popup не должен падать вниз в соседнюю секцию
+
+### Portal anchor snippet
+```tsx
+{isOpen &&
+ typeof document !== "undefined" &&
+ createPortal(
+
+
+ ...
+
+ ,
+ document.body
+ )}
+```
+
+### Reusable классы
+- Accent CTA:
+ - `.nodedc-external-primary-button`
+ - текст внутри всегда `#0b1117`
+- Secondary action:
+ - `.nodedc-external-action-button`
+- Secondary icon action:
+ - `.nodedc-external-icon-button`
+- Readonly property/control surface:
+ - `.nodedc-external-readonly-value`
+ - `.nodedc-modal-field`
+- External property rows:
+ - `.nodedc-external-property-row`
+ - `.nodedc-external-property-label`
+ - `.nodedc-external-property-value`
+ - `.nodedc-external-property-control`
+- Dropdown shell:
+ - `.nodedc-dropdown-surface`
+ - `.nodedc-dropdown-search`
+ - `.nodedc-dropdown-option`
+- External contour card/shell:
+ - `.nodedc-external-card`
+ - `.nodedc-external-section`
+ - `.nodedc-external-content-shell`
+- Intake filter chips:
+ - `.nodedc-filter-chip`
+
+### Anchor snippets
+```tsx
+...
+```
+
+```tsx
+
+```
+
+```tsx
+
+
+ ...
+
+```
+
+```tsx
+
+```
+
+```tsx
+
+
+ Value
+
+ }
+/>
+```
+
+## Drag and drop
+- Drag overlay использует акцентный контур.
+- Во внутреннем kanban после успешного ручного переноса карточка остается в активной заливке `active_card_rgb` до выбора другой карточки/следующего переноса; сам drag-жест не открывает detail pane.
+- Delete dropzone:
+ - без красного технического свечения и без red-tinted text/fill
+ - текст локализован
+ - акцентный outline обязателен
+
+## Тексты
+- Пользовательский UI на русском, если экран русифицирован.
+- Не оставлять смешанные подписи вида `Created at / Updated at / Label / State group`, если экран уже на русском.
+
+## Правило внедрения
+- Новый экран или popup не стилизуется локально “на глаз”.
+- Сначала используется существующий shared-класс или shared-component.
+- Если shared-слоя нет, создаётся reusable-класс/компонент и уже через него приводятся все похожие места.
+- Цель: не точечная покраска одного окна, а единый системный канон.
+- Если блок визуально расходится со стилем системы, не добавлять поверх временную wrapper-заплатку. Нужно либо перевести блок на shared-компонент, либо переверстать локальную структуру под shared-классы.
+- Для экранов со вкладками/переключателями нельзя оставлять flash старой верстки. Перед refetch нужно очищать stale store-data и показывать loading shell.
+- Если карточки или списки разных модулей должны быть одинаковыми по канону, нельзя лечить это внешней обёрткой. Нужно менять сам внутренний layout item-компонента.
+- Для `Внешних контуров` это значит:
+ - список карточек правится на уровне `list-item.tsx`, а не через внешний wrapper
+ - gap между карточками должен совпадать с каноном `Внутреннего контура`
+ - актуальный gap списка на текущем каноне: `space-y-3`
+ - при tab switch между `Открытые / Закрытые` нельзя полагаться только на route param; нужен локальный `pendingTab`, чтобы stale layout не мелькал до завершения refetch
+ - toolbar-навигация и inline actions не должны использовать старые квадратные `IconButton` остатки
+ - свойства `Приоритет / Метки / Статус` не должны рисовать внутренние boxed-chip артефакты
+ - popup `Приоритет / Метки` не может визуально жить внутри property-row; если он открывается из blur-shell, он обязан уходить в portal и рендериться над секцией
+ - filled CTA вроде `Добавить запрос` используют `nodedc-external-primary-button` и всегда имеют тёмный текст
+ - filled CTA используют чёрный/почти-чёрный текст всегда; белый текст на светлом акценте запрещён
+ - secondary meta-иконки в карточке списка не должны иметь отдельную серую подложку, если по канону это простой inline icon
+ - empty-state не должен использовать декоративную серую подложку под SVG; media-box прозрачный, SVG выравнивается через `display:flex` и центрирование
+ - detail-toolbar в карточке запроса использует общий glass-cluster для листания `prev/next`, а сами кнопки внутри кластера — круглые, без квадратной подложки
+ - `Добавить запрос` в header `Внешних контуров` — это filled accent CTA с тёмным текстом, каноничным радиусом и hover в более светлый тон того же акцента
+ - global sidebar quick action `Новый рабочий элемент` не показывается на маршруте `external-contours`, потому что этот экран уже имеет собственный primary CTA в header
+ - active/passive карточки `Внешних контуров` обязаны брать фон только из `--nodedc-card-active-rgb` и `--nodedc-card-passive-rgb`
+ - header `Внешних контуров` и detail-pane опускаются на единый верхний ритм; нельзя прижимать breadcrumbs, CTA и detail-header к верхней кромке
+- Для `Предложений / Intake` это значит:
+ - правая detail-pane не растягивается на всю свободную ширину экрана; она использует тот же `IssueView` side-peek shell и тот же persisted width, что и `Внутренний контур`
+ - top-toolbar `Предложений` не верстается отдельной локальной шапкой; используется тот же peek header, что и у `Внутреннего контура`, а intake-специфичные actions добавляются только как slot
+ - `Открытые / Закрытые` не живут отдельными tab-кнопками внутри левой колонки; для intake статус — это обычный filter, а не отдельный режим layout
+ - кнопки `Фильтры / Сортировка` не остаются внутри списка intake; они выносятся в верхний header cluster по тому же паттерну, что и у `Внутреннего контура`
+ - dropdown фильтров и сортировки не могут жить под карточками списка; popup обязан иметь верхний z-layer и не конфликтовать со scroll/list слоями
+ - search shell внутри intake filter dropdown использует тот же matte glass, что и остальные dropdown/popup
+ - applied filter chips в intake не используют старые `Tag`-плашки `Plane`; они приводятся к glass-chip канону через `.nodedc-filter-chip`
+ - intake-list использует тот же shared `nodedc-work-item-card` shell, что и карточка `Внутреннего контура`; intake допускает только контекстные отличия в meta/footer, а не отдельную геометрию карточки
+ - правая detail-pane `Предложений` не изобретает собственные section-shell; title, description, properties и activity используют тот же peek/details rhythm, что и `Внутреннем контуре`
+ - режим `full-screen` у detail-pane переводит свойства в правую колонку по тому же принципу, что и в `Внутреннем контуре`
+ - activity/comment composer внутри узкой detail-pane должен использовать compact peek-канон, а не растянутый page-form вид
+ - header intake-detail не использует внешнеконтурный toolbar как есть; sequence pill, status pill и decision buttons собираются в один compact peek-row без вылета за край detail-pane
+ - CTA `Принять / Отклонить` в intake-detail не могут иметь фиксированную ширину, которая ломает side-peek; на светлом accent-fill текст всегда тёмный, hover идёт в более светлый тон того же акцента
+ - модалка `Создать входящий рабочий элемент` центрируется как остальные create/edit modal, использует glass shell, `nodedc-modal-input`, `nodedc-modal-editor`, `nodedc-modal-primary-button` и `nodedc-modal-secondary-button`
+ - quick-actions menu по троеточию на карточке обязано открываться из корректного viewport-anchor без оффсета; если локальный card-layer ломает геометрию, menu возвращается в `body` portal, но сохраняет правильный z-layer и привязку к trigger
+ - quick-actions по троеточию не реализуются как отдельный спец-вид меню; они используют тот же popper/portal dropdown-паттерн, что и рабочие меню `Статус / Приоритет`, чтобы trigger, offset и z-layer вели себя одинаково
+ - реализация quick-actions выносится в shared `ActionDropdown`; карточки и detail-view не держат собственный `isMenuActive`, локальный outside-click и отдельный anchor-state для `...`
+- popup выбора `Приоритет / Метки` внутри detail view не рендерится inline в property-row; он обязан уходить в `portal`
+- секции с dropdown-trigger внутри blur/glass shell обязаны иметь `overflow: visible` и `isolation: isolate`, иначе popup визуально “тонет” внутри блока
+- при переключении `Открытые / Закрытые` store обязан очистить stale request list до нового fetch, чтобы пользователь не видел flash старой верстки
+- карточка списка `Внешних контуров` правится на уровне `list-item.tsx`, а не внешней обёрткой:
+ - верхняя и нижняя оси собираются как у карточки `Внутреннего контура`
+ - gap между карточками совпадает с каноном `Внутреннего контура`
+- empty-state иконки без декоративной подложки; если иконка визуально “плывёт”, корректируется сам SVG/media-box
+
+### Внешние контуры: code anchors
+- Header CTA:
+```tsx
+...
+```
+
+- Route-aware quick action hide:
+```tsx
+const pathname = usePathname();
+if (pathname?.includes("/external-contours")) return null;
+```
+
+- List spacing:
+```tsx
+
+ {filteredRequestIds.map((requestId) => (
+
+ ))}
+
+```
+
+- Pending tab anti-flash:
+```tsx
+const [pendingTab, setPendingTab] = useState(null);
+const routeTab = (searchParams.get("currentTab") as TInboxIssueCurrentTab | null) ?? currentTab;
+const resolvedTab = pendingTab ?? routeTab;
+const isTabTransitioning = loader === "init-loading" || pendingTab !== null || routeTab !== currentTab;
+```
+
+- Card theme source:
+```css
+.nodedc-external-card {
+ background: rgb(var(--nodedc-card-passive-rgb));
+}
+
+.nodedc-external-card[data-active="true"] {
+ background: rgb(var(--nodedc-card-active-rgb));
+ color: #0b1117;
+}
+```
+
+- Property popup anchor:
+```tsx
+
+ ...
+
+ }
+/>
+```
+
+- Detail toolbar cluster:
+```tsx
+
+ ...
+ ...
+
+```
+
+- Property control:
+```tsx
+
+
+ ...
+
+```
+
+- Root tab switch without stale flash:
+```tsx
+const [pendingTab, setPendingTab] = useState(null);
+const resolvedTab = pendingTab ?? routeTab;
+const isTabTransitioning = loader === "init-loading" || pendingTab !== null || routeTab !== currentTab;
+
+if (resolvedTab !== nextTab) {
+ setPendingTab(nextTab);
+ void handleCurrentTab(workspaceSlug, projectId, nextTab);
+ router.push(`...currentTab=${nextTab}`);
+}
+```
+
+- Store-side tab reset:
+```ts
+this.requestIds = [];
+this.requests = {};
+this.loader = "init-loading";
+this.currentTab = tab;
+```
+
+- Portal popup с фиксированной стратегией:
+```tsx
+const { styles, attributes } = usePopper(referenceElement, popperElement, {
+ strategy: "fixed",
+ placement: placement ?? "bottom-start",
+});
+```
+
+- Property popup without boxed artifact:
+```tsx
+
+
+ ...
+
+ }
+/>
+```
+
+- Контейнер секции с trigger:
+```tsx
+
+ ...
+
+```
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..d5992b9
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ NODE.DC Launcher
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..de03176
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2298 @@
+{
+ "name": "nodedc-launcher",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "nodedc-launcher",
+ "version": "0.1.0",
+ "dependencies": {
+ "lucide-react": "^0.468.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.1.13",
+ "@types/react-dom": "^19.1.9",
+ "@vitejs/plugin-react": "^5.0.4",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.24",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
+ "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001791",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+ "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.348",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz",
+ "integrity": "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.468.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
+ "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2ced347
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "nodedc-launcher",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --host 0.0.0.0",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "lucide-react": "^0.468.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.1.13",
+ "@types/react-dom": "^19.1.9",
+ "@vitejs/plugin-react": "^5.0.4",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/public/media/launcher-stage.gif b/public/media/launcher-stage.gif
new file mode 100644
index 0000000..a3f8c35
Binary files /dev/null and b/public/media/launcher-stage.gif differ
diff --git a/public/nodedc-logo.svg b/public/nodedc-logo.svg
new file mode 100644
index 0000000..92b19d8
--- /dev/null
+++ b/public/nodedc-logo.svg
@@ -0,0 +1 @@
+
diff --git a/public/nodedc-mark.svg b/public/nodedc-mark.svg
new file mode 100644
index 0000000..836bf0e
--- /dev/null
+++ b/public/nodedc-mark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/public/storage/default.gif b/public/storage/default.gif
new file mode 100644
index 0000000..a3f8c35
Binary files /dev/null and b/public/storage/default.gif differ
diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json
new file mode 100644
index 0000000..078cc44
--- /dev/null
+++ b/public/storage/launcher-data.json
@@ -0,0 +1,567 @@
+{
+ "clients": [
+ {
+ "id": "client_romashka",
+ "type": "company",
+ "name": "ООО Ромашка",
+ "legalName": "ООО Ромашка",
+ "status": "active",
+ "demoEndsAt": null,
+ "contactName": "Иван Петров",
+ "contactEmail": "ivan@romashka.ru",
+ "notes": "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "client_roga_kopyta",
+ "type": "company",
+ "name": "ООО Рога и Копыта",
+ "legalName": "ООО Рога и Копыта",
+ "status": "demo",
+ "demoEndsAt": "2026-06-01T00:00:00Z",
+ "contactName": "Мария Иванова",
+ "contactEmail": "maria@example.ru",
+ "notes": "Клиент на демо-доступе, подключены только базовые сервисы.",
+ "createdAt": "2026-04-10T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "client_private_architect",
+ "type": "person",
+ "name": "Илья Архитектор",
+ "legalName": null,
+ "status": "suspended",
+ "demoEndsAt": "2026-04-20T00:00:00Z",
+ "contactName": "Илья Архитектор",
+ "contactEmail": "ilya@example.ru",
+ "notes": "Пример приостановленного частного клиента.",
+ "createdAt": "2026-03-14T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "users": [
+ {
+ "id": "user_root",
+ "authentikUserId": "ak-root",
+ "name": "Root Admin",
+ "email": "root@nodedc.local",
+ "globalStatus": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "user_ivan",
+ "authentikUserId": "ak-ivan",
+ "name": "Иван Петров",
+ "email": "ivan@romashka.ru",
+ "globalStatus": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "user_vera",
+ "authentikUserId": "ak-vera",
+ "name": "Вера Соколова",
+ "email": "vera@romashka.ru",
+ "globalStatus": "active",
+ "createdAt": "2026-04-02T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "user_vasya",
+ "authentikUserId": "ak-vasya",
+ "name": "Василий Орлов",
+ "email": "vasya@romashka.ru",
+ "globalStatus": "active",
+ "createdAt": "2026-04-05T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "user_lena",
+ "authentikUserId": "ak-lena",
+ "name": "Лена Волкова",
+ "email": "lena@romashka.ru",
+ "globalStatus": "active",
+ "createdAt": "2026-04-08T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "user_maria",
+ "authentikUserId": "ak-maria",
+ "name": "Мария Иванова",
+ "email": "maria@example.ru",
+ "globalStatus": "active",
+ "createdAt": "2026-04-10T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "user_blocked",
+ "authentikUserId": "ak-blocked",
+ "name": "Олег Заблокирован",
+ "email": "oleg@romashka.ru",
+ "globalStatus": "blocked",
+ "createdAt": "2026-04-12T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "memberships": [
+ {
+ "id": "mem_ivan_romashka",
+ "clientId": "client_romashka",
+ "userId": "user_ivan",
+ "role": "client_owner",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "mem_vera_romashka",
+ "clientId": "client_romashka",
+ "userId": "user_vera",
+ "role": "client_admin",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "mem_vasya_romashka",
+ "clientId": "client_romashka",
+ "userId": "user_vasya",
+ "role": "member",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "mem_lena_romashka",
+ "clientId": "client_romashka",
+ "userId": "user_lena",
+ "role": "member",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "mem_blocked_romashka",
+ "clientId": "client_romashka",
+ "userId": "user_blocked",
+ "role": "member",
+ "status": "disabled",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "mem_maria_roga",
+ "clientId": "client_roga_kopyta",
+ "userId": "user_maria",
+ "role": "client_owner",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "mem_ivan_roga",
+ "clientId": "client_roga_kopyta",
+ "userId": "user_ivan",
+ "role": "client_admin",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "groups": [
+ {
+ "id": "group_romashka_leads",
+ "clientId": "client_romashka",
+ "name": "Руководство",
+ "description": "Собственники и руководители клиента.",
+ "memberIds": [
+ "user_ivan",
+ "user_vera"
+ ],
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "group_romashka_accounting",
+ "clientId": "client_romashka",
+ "name": "Бухгалтерия",
+ "description": "1C и финансовые сценарии.",
+ "memberIds": [
+ "user_lena"
+ ],
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "group_romashka_ops",
+ "clientId": "client_romashka",
+ "name": "Операторы",
+ "description": "Ежедневная работа в задачах и тендерах.",
+ "memberIds": [
+ "user_vasya",
+ "user_lena"
+ ],
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "group_roga_demo",
+ "clientId": "client_roga_kopyta",
+ "name": "Демо-команда",
+ "description": "Пилотный контур клиента.",
+ "memberIds": [
+ "user_maria",
+ "user_ivan"
+ ],
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "services": [
+ {
+ "id": "service_nodedc",
+ "slug": "nodedc",
+ "title": "NodeDC",
+ "subtitle": "Агентная платформа",
+ "description": "Сборка, запуск и мониторинг агентных workflow.",
+ "fullDescription": "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
+ "url": "https://dev.handhdc.ru",
+ "launchUrl": "https://dev.handhdc.ru/sso/launch",
+ "accentColor": "#B5FF5A",
+ "fallbackGradient": "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)",
+ "status": "active",
+ "order": 10,
+ "authentikApplicationSlug": "nodedc",
+ "authentikGroupName": "service-nodedc",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T15:29:56.933Z",
+ "coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg",
+ "coverMediaKind": "image",
+ "coverMediaSource": "file",
+ "coverMediaFileName": "1777649382309-49d8c393-2026-05-01-17.31.21.jpg",
+ "ambientVideoUrl": "/storage/uploads/1777649395844-a878de95-090aff247929335.69e6521716ea9.gif",
+ "ambientMediaKind": "gif",
+ "ambientMediaSource": "file",
+ "ambientMediaFileName": "1777649395844-a878de95-090aff247929335.69e6521716ea9.gif"
+ },
+ {
+ "id": "service_task_manager",
+ "slug": "task-manager",
+ "title": "Task Manager",
+ "subtitle": "Операционный слой",
+ "description": "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
+ "fullDescription": "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
+ "url": "https://tasks.handhdc.ru",
+ "launchUrl": "https://tasks.handhdc.ru/sso/launch",
+ "accentColor": "#D7C8FF",
+ "fallbackGradient": "linear-gradient(132deg, rgba(215, 200, 255, 0.82), rgba(51, 41, 79, 0.9) 46%, #0B0D10 84%)",
+ "status": "active",
+ "order": 20,
+ "authentikApplicationSlug": "task-manager",
+ "authentikGroupName": "service-task-manager",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "service_1c",
+ "slug": "1c-assistant",
+ "title": "1C Assistant",
+ "subtitle": "Бухгалтерский ассистент",
+ "description": "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
+ "fullDescription": "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
+ "url": "https://1c.handhdc.ru",
+ "launchUrl": "https://1c.handhdc.ru/sso/launch",
+ "accentColor": "#8FD7FF",
+ "fallbackGradient": "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)",
+ "status": "maintenance",
+ "order": 30,
+ "authentikApplicationSlug": "1c-assistant",
+ "authentikGroupName": "service-1c-assistant",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "service_tender",
+ "slug": "tender-agent",
+ "title": "Tender Agent",
+ "subtitle": "Госзакупки и тендеры",
+ "description": "Поиск, анализ и подготовка тендерных решений.",
+ "fullDescription": "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
+ "url": "https://tender.handhdc.ru",
+ "launchUrl": "https://tender.handhdc.ru/sso/launch",
+ "accentColor": "#FFD166",
+ "fallbackGradient": "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)",
+ "status": "active",
+ "order": 40,
+ "authentikApplicationSlug": "tender-agent",
+ "authentikGroupName": "service-tender-agent",
+ "createdAt": "2026-04-03T10:00:00Z",
+ "updatedAt": "2026-05-01T15:37:01.591Z",
+ "coverImageUrl": "/storage/uploads/1777649809136-7935fb4d-2026-05-01-18.36.20.jpg",
+ "coverMediaKind": "image",
+ "coverMediaSource": "file",
+ "coverMediaFileName": "1777649809136-7935fb4d-2026-05-01-18.36.20.jpg"
+ },
+ {
+ "id": "service_digital_twin",
+ "slug": "digital-twin",
+ "title": "Digital Twin",
+ "subtitle": "3D и пространственные данные",
+ "description": "Просмотр цифровых двойников, карт и объектных сцен.",
+ "fullDescription": "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
+ "url": "https://twin.handhdc.ru",
+ "launchUrl": "https://twin.handhdc.ru/sso/launch",
+ "accentColor": "#76E4F7",
+ "fallbackGradient": "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
+ "status": "active",
+ "order": 50,
+ "authentikApplicationSlug": "digital-twin",
+ "authentikGroupName": "service-digital-twin",
+ "createdAt": "2026-04-05T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "service_dm",
+ "slug": "digital-modules",
+ "title": "Digital Modules",
+ "subtitle": "Будущие модули",
+ "description": "Скрытый каталог модулей для root-admin preview.",
+ "fullDescription": "Площадка для будущих цифровых модулей NODE.DC.",
+ "url": "https://dm.handhdc.ru",
+ "launchUrl": "https://dm.handhdc.ru/sso/launch",
+ "accentColor": "#FF9AC2",
+ "fallbackGradient": "linear-gradient(135deg, rgba(255, 154, 194, 0.78), rgba(76, 41, 64, 0.9) 44%, #090B0F 86%)",
+ "status": "hidden",
+ "order": 60,
+ "authentikApplicationSlug": "digital-modules",
+ "authentikGroupName": "service-digital-modules",
+ "createdAt": "2026-04-10T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "service_internal",
+ "slug": "internal-tools",
+ "title": "Internal Tools",
+ "subtitle": "Внутренний контур",
+ "description": "Отключённый сервис для проверки диагностики root-admin.",
+ "fullDescription": "Не показывается обычным пользователям, виден root-admin в каталоге.",
+ "url": "https://internal.handhdc.ru",
+ "launchUrl": null,
+ "accentColor": "#F97373",
+ "fallbackGradient": "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
+ "status": "disabled",
+ "order": 70,
+ "authentikApplicationSlug": "internal-tools",
+ "authentikGroupName": "service-internal-tools",
+ "createdAt": "2026-04-12T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "grants": [
+ {
+ "id": "grant_romashka_task",
+ "serviceId": "service_task_manager",
+ "targetType": "client",
+ "targetId": "client_romashka",
+ "appRole": "member",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "grant_romashka_nodedc_leads",
+ "serviceId": "service_nodedc",
+ "targetType": "group",
+ "targetId": "group_romashka_leads",
+ "appRole": "admin",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "grant_romashka_1c_accounting",
+ "serviceId": "service_1c",
+ "targetType": "group",
+ "targetId": "group_romashka_accounting",
+ "appRole": "member",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "grant_romashka_tender_ops",
+ "serviceId": "service_tender",
+ "targetType": "group",
+ "targetId": "group_romashka_ops",
+ "appRole": "viewer",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "grant_romashka_twin_vasya",
+ "serviceId": "service_digital_twin",
+ "targetType": "user",
+ "targetId": "user_vasya",
+ "appRole": "viewer",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "grant_roga_task",
+ "serviceId": "service_task_manager",
+ "targetType": "client",
+ "targetId": "client_roga_kopyta",
+ "appRole": "member",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "grant_roga_nodedc",
+ "serviceId": "service_nodedc",
+ "targetType": "client",
+ "targetId": "client_roga_kopyta",
+ "appRole": "viewer",
+ "status": "active",
+ "createdAt": "2026-04-01T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "exceptions": [
+ {
+ "id": "exception_lena_task_deny",
+ "serviceId": "service_task_manager",
+ "userId": "user_lena",
+ "type": "deny",
+ "reason": "Индивидуально отключён Task Manager на период ревизии доступа.",
+ "createdAt": "2026-04-28T10:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "invites": [
+ {
+ "id": "invite_romashka_analyst",
+ "clientId": "client_romashka",
+ "email": "analyst@romashka.ru",
+ "role": "member",
+ "invitedByUserId": "user_ivan",
+ "token": "romashka-analyst-demo",
+ "expiresAt": "2026-05-15T12:00:00Z",
+ "status": "sent",
+ "createdAt": "2026-04-30T12:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "invite_roga_admin",
+ "clientId": "client_roga_kopyta",
+ "email": "ops@example.ru",
+ "role": "client_admin",
+ "invitedByUserId": "user_maria",
+ "token": "roga-admin-demo",
+ "expiresAt": "2026-05-18T12:00:00Z",
+ "status": "created",
+ "createdAt": "2026-04-30T14:00:00Z",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "syncStatuses": [
+ {
+ "id": "sync_romashka_auth",
+ "objectId": "client_romashka",
+ "objectName": "ООО Ромашка",
+ "objectType": "client",
+ "target": "authentik",
+ "state": "synced",
+ "lastSyncAt": "2026-05-01T08:00:00Z",
+ "error": null,
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "sync_task_auth",
+ "objectId": "service_task_manager",
+ "objectName": "Task Manager",
+ "objectType": "service",
+ "target": "authentik",
+ "state": "synced",
+ "lastSyncAt": "2026-05-01T08:00:00Z",
+ "error": null,
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "sync_lena_task",
+ "objectId": "exception_lena_task_deny",
+ "objectName": "Deny: Лена / Task Manager",
+ "objectType": "grant",
+ "target": "task_manager",
+ "state": "pending",
+ "lastSyncAt": null,
+ "error": null,
+ "updatedAt": "2026-05-01T09:00:00Z"
+ },
+ {
+ "id": "sync_roga_nodedc",
+ "objectId": "client_roga_kopyta",
+ "objectName": "ООО Рога и Копыта",
+ "objectType": "client",
+ "target": "nodedc",
+ "state": "error",
+ "lastSyncAt": "2026-05-01T08:00:00Z",
+ "error": "OIDC binding ещё не создан для demo-клиента.",
+ "updatedAt": "2026-05-01T09:00:00Z"
+ }
+ ],
+ "auditEvents": [
+ {
+ "id": "audit_1",
+ "at": "2026-05-01T08:40:00Z",
+ "actorUserId": "user_root",
+ "actorName": "Root Admin",
+ "action": "Создан сервис",
+ "objectType": "service",
+ "objectName": "Digital Modules",
+ "clientId": "client_romashka",
+ "result": "success",
+ "details": null
+ },
+ {
+ "id": "audit_2",
+ "at": "2026-05-01T08:20:00Z",
+ "actorUserId": "user_ivan",
+ "actorName": "Иван Петров",
+ "action": "Создан invite",
+ "objectType": "invite",
+ "objectName": "analyst@romashka.ru",
+ "clientId": "client_romashka",
+ "result": "success",
+ "details": "Срок действия до 15.05.2026"
+ },
+ {
+ "id": "audit_3",
+ "at": "2026-04-30T17:10:00Z",
+ "actorUserId": "user_root",
+ "actorName": "Root Admin",
+ "action": "Создано deny-исключение",
+ "objectType": "access",
+ "objectName": "Лена / Task Manager",
+ "clientId": "client_romashka",
+ "result": "warning",
+ "details": "Индивидуальное правило перекрыло client grant."
+ },
+ {
+ "id": "audit_4",
+ "at": "2026-04-30T16:00:00Z",
+ "actorUserId": "user_root",
+ "actorName": "Root Admin",
+ "action": "Ошибка синхронизации",
+ "objectType": "sync",
+ "objectName": "ООО Рога и Копыта / NodeDC",
+ "clientId": "client_romashka",
+ "result": "error",
+ "details": "Нет application binding."
+ }
+ ]
+}
diff --git a/public/storage/uploads/.gitkeep b/public/storage/uploads/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/public/storage/uploads/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/public/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg b/public/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg
new file mode 100644
index 0000000..af01e06
Binary files /dev/null and b/public/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg differ
diff --git a/public/storage/uploads/1777649395844-a878de95-090aff247929335.69e6521716ea9.gif b/public/storage/uploads/1777649395844-a878de95-090aff247929335.69e6521716ea9.gif
new file mode 100644
index 0000000..07f9b5b
Binary files /dev/null and b/public/storage/uploads/1777649395844-a878de95-090aff247929335.69e6521716ea9.gif differ
diff --git a/public/storage/uploads/1777649809136-7935fb4d-2026-05-01-18.36.20.jpg b/public/storage/uploads/1777649809136-7935fb4d-2026-05-01-18.36.20.jpg
new file mode 100644
index 0000000..6d59807
Binary files /dev/null and b/public/storage/uploads/1777649809136-7935fb4d-2026-05-01-18.36.20.jpg differ
diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx
new file mode 100644
index 0000000..b24c34e
--- /dev/null
+++ b/src/app/LauncherApp.tsx
@@ -0,0 +1,272 @@
+import { useEffect, useMemo, useState } from "react";
+import type { ServiceAccessException, ServiceGrant } from "../entities/access/types";
+import type { Invite } from "../entities/invite/types";
+import type { LauncherServiceView, Service } from "../entities/service/types";
+import type { SyncStatus } from "../entities/sync/types";
+import {
+ buildLauncherServices,
+ buildMe,
+ initialLauncherData,
+ profileOptions,
+ type LauncherData,
+} from "../shared/api/mockApi";
+import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
+import { AdminOverlay } from "../widgets/admin-overlay/AdminOverlay";
+import { ServiceRail } from "../widgets/service-rail/ServiceRail";
+import { ServiceStage } from "../widgets/service-stage/ServiceStage";
+import { TopBar } from "../widgets/top-bar/TopBar";
+
+export function LauncherApp() {
+ const [data, setData] = useState(initialLauncherData);
+ const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
+ const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
+ const [selectedServiceId, setSelectedServiceId] = useState();
+ const [adminOpen, setAdminOpen] = useState(false);
+ const [storageHydrated, setStorageHydrated] = useState(false);
+
+ const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
+ const resolvedClientId = me.activeClientId;
+ const launcherServices = useMemo(
+ () => buildLauncherServices(data, activeProfileId, resolvedClientId),
+ [data, activeProfileId, resolvedClientId]
+ );
+
+ useEffect(() => {
+ if (!launcherServices.length) {
+ setSelectedServiceId(undefined);
+ return;
+ }
+
+ if (selectedServiceId && !launcherServices.some((service) => service.id === selectedServiceId)) {
+ setSelectedServiceId(undefined);
+ }
+ }, [launcherServices, selectedServiceId]);
+
+ const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ loadPersistedLauncherData()
+ .then((persistedData) => {
+ if (isMounted && persistedData) {
+ setData(persistedData);
+ }
+ })
+ .finally(() => {
+ if (isMounted) {
+ setStorageHydrated(true);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!storageHydrated) return;
+
+ const saveTimer = window.setTimeout(() => {
+ persistLauncherData(data).catch((error: unknown) => {
+ console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины");
+ });
+ }, 350);
+
+ return () => window.clearTimeout(saveTimer);
+ }, [data, storageHydrated]);
+
+ function handleProfileChange(userId: string) {
+ const profile = profileOptions.find((option) => option.userId === userId);
+ setActiveProfileId(userId);
+ setActiveClientId(profile?.defaultClientId ?? activeClientId);
+ setAdminOpen(false);
+ }
+
+ function handleLaunch(service: LauncherServiceView) {
+ if (!service.openUrl || !service.effectiveAccess.openEnabled) return;
+ window.open(service.openUrl, "_blank", "noopener,noreferrer");
+ }
+
+ function handleServiceSelect(serviceId: string) {
+ setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId));
+ }
+
+ function handleCreateGrant(grant: Omit) {
+ setData((current) => ({
+ ...current,
+ grants: [
+ ...current.grants,
+ {
+ ...grant,
+ id: `grant_mock_${Date.now()}`,
+ status: "active",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ],
+ }));
+ }
+
+ function handleCreateDenyException(exception: Omit) {
+ setData((current) => ({
+ ...current,
+ exceptions: [
+ ...current.exceptions.filter(
+ (item) => !(item.serviceId === exception.serviceId && item.userId === exception.userId && item.type === "deny")
+ ),
+ {
+ ...exception,
+ id: `exception_mock_${Date.now()}`,
+ type: "deny",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ],
+ }));
+ }
+
+ function handleRemoveException(exceptionId: string) {
+ setData((current) => ({
+ ...current,
+ exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId),
+ }));
+ }
+
+ function handleCreateInvite(invite: Pick) {
+ setData((current) => ({
+ ...current,
+ invites: [
+ {
+ ...invite,
+ id: `invite_mock_${Date.now()}`,
+ invitedByUserId: me.user.id,
+ token: `mock-${Date.now()}`,
+ expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
+ status: "created",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ...current.invites,
+ ],
+ }));
+ }
+
+ function handleRetrySync(syncId: string) {
+ setData((current) => ({
+ ...current,
+ syncStatuses: current.syncStatuses.map((sync): SyncStatus =>
+ sync.id === syncId
+ ? {
+ ...sync,
+ state: "pending",
+ error: null,
+ updatedAt: new Date().toISOString(),
+ }
+ : sync
+ ),
+ }));
+ }
+
+ function handleUpdateService(serviceId: string, patch: Partial) {
+ setData((current) => ({
+ ...current,
+ services: current.services.map((service) =>
+ service.id === serviceId
+ ? {
+ ...service,
+ ...patch,
+ updatedAt: new Date().toISOString(),
+ }
+ : service
+ ),
+ }));
+ }
+
+ function handleCreateService() {
+ const createdAt = new Date().toISOString();
+
+ setData((current) => {
+ const nextOrder = Math.max(0, ...current.services.map((service) => service.order)) + 10;
+ const id = `service_mock_${Date.now()}`;
+
+ return {
+ ...current,
+ services: [
+ ...current.services,
+ {
+ id,
+ slug: `new-service-${current.services.length + 1}`,
+ title: "New Service",
+ subtitle: "Новый сервис",
+ description: "Описание сервиса для витрины.",
+ fullDescription: "Заполните описание, медиа и ссылку запуска в редакторе контента.",
+ url: "https://service.handhdc.ru",
+ launchUrl: "https://service.handhdc.ru/sso/launch",
+ accentColor: "#F7F8F4",
+ fallbackGradient: "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
+ coverMediaSource: "url",
+ coverMediaKind: "image",
+ ambientMediaSource: "url",
+ ambientMediaKind: "gif",
+ status: "hidden",
+ order: nextOrder,
+ authentikApplicationSlug: `new-service-${current.services.length + 1}`,
+ authentikGroupName: `service-new-${current.services.length + 1}`,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ ],
+ };
+ });
+ }
+
+ function handleDeleteService(serviceId: string) {
+ setData((current) => ({
+ ...current,
+ services: current.services.filter((service) => service.id !== serviceId),
+ grants: current.grants.filter((grant) => grant.serviceId !== serviceId),
+ exceptions: current.exceptions.filter((exception) => exception.serviceId !== serviceId),
+ }));
+
+ setSelectedServiceId((current) => (current === serviceId ? undefined : current));
+ }
+
+ return (
+
+
setAdminOpen(true)}
+ onOpenShowcase={() => setAdminOpen(false)}
+ />
+
+
+ 0} onLaunch={handleLaunch} />
+ {adminOpen && me.permissions.canOpenAdmin ? (
+ setAdminOpen(false)}
+ onCreateGrant={handleCreateGrant}
+ onCreateDenyException={handleCreateDenyException}
+ onRemoveException={handleRemoveException}
+ onCreateInvite={handleCreateInvite}
+ onRetrySync={handleRetrySync}
+ onUpdateService={handleUpdateService}
+ onCreateService={handleCreateService}
+ onDeleteService={handleDeleteService}
+ />
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/entities/access/computeEffectiveAccess.test.ts b/src/entities/access/computeEffectiveAccess.test.ts
new file mode 100644
index 0000000..0fdcba1
--- /dev/null
+++ b/src/entities/access/computeEffectiveAccess.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from "vitest";
+import { computeEffectiveAccess } from "./computeEffectiveAccess";
+import type { Client } from "../client/types";
+import type { Service } from "../service/types";
+import type { ClientGroup, ClientMembership, LauncherUser } from "../user/types";
+import type { ServiceAccessException, ServiceGrant } from "./types";
+
+const client: Client = {
+ id: "client_a",
+ type: "company",
+ name: "ООО Тест",
+ status: "active",
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+};
+
+const user: LauncherUser = {
+ id: "user_a",
+ name: "Пользователь",
+ email: "user@example.ru",
+ globalStatus: "active",
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+};
+
+const membership: ClientMembership = {
+ id: "membership_a",
+ clientId: client.id,
+ userId: user.id,
+ role: "member",
+ status: "active",
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+};
+
+const group: ClientGroup = {
+ id: "group_a",
+ clientId: client.id,
+ name: "Группа",
+ memberIds: [user.id],
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+};
+
+const service: Service = {
+ id: "service_a",
+ slug: "service-a",
+ title: "Service A",
+ description: "Demo service",
+ url: "https://example.ru",
+ status: "active",
+ order: 1,
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+};
+
+const baseInput = {
+ client,
+ user,
+ membership,
+ userGroups: [group],
+ service,
+ grants: [] as ServiceGrant[],
+ exceptions: [] as ServiceAccessException[],
+};
+
+describe("computeEffectiveAccess", () => {
+ it("returns false when client is suspended", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ client: { ...client, status: "suspended" },
+ });
+
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toContain("Клиент");
+ });
+
+ it("returns false when user is blocked", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ user: { ...user, globalStatus: "blocked" },
+ });
+
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toContain("Пользователь");
+ });
+
+ it("returns false when no grant exists", () => {
+ const result = computeEffectiveAccess(baseInput);
+
+ expect(result.allowed).toBe(false);
+ expect(result.visible).toBe(false);
+ });
+
+ it("returns true from client grant", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ grants: [grant("client", client.id)],
+ });
+
+ expect(result.allowed).toBe(true);
+ expect(result.source).toBe("client");
+ });
+
+ it("returns true from group grant", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ grants: [grant("group", group.id)],
+ });
+
+ expect(result.allowed).toBe(true);
+ expect(result.source).toBe("group");
+ });
+
+ it("returns true from user grant", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ grants: [grant("user", user.id)],
+ });
+
+ expect(result.allowed).toBe(true);
+ expect(result.source).toBe("user");
+ });
+
+ it("deny exception overrides user grant", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ grants: [grant("user", user.id)],
+ exceptions: [deny()],
+ });
+
+ expect(result.allowed).toBe(false);
+ expect(result.source).toBe("exception");
+ });
+
+ it("maintenance service is visible but openEnabled is false", () => {
+ const result = computeEffectiveAccess({
+ ...baseInput,
+ service: { ...service, status: "maintenance" },
+ grants: [grant("client", client.id)],
+ });
+
+ expect(result.visible).toBe(true);
+ expect(result.openEnabled).toBe(false);
+ });
+});
+
+function grant(targetType: ServiceGrant["targetType"], targetId: string): ServiceGrant {
+ return {
+ id: `grant_${targetType}`,
+ serviceId: service.id,
+ targetType,
+ targetId,
+ appRole: "member",
+ status: "active",
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+ };
+}
+
+function deny(): ServiceAccessException {
+ return {
+ id: "exception_deny",
+ serviceId: service.id,
+ userId: user.id,
+ type: "deny",
+ reason: "Тестовый deny",
+ createdAt: "2026-04-01T00:00:00Z",
+ updatedAt: "2026-04-01T00:00:00Z",
+ };
+}
diff --git a/src/entities/access/computeEffectiveAccess.ts b/src/entities/access/computeEffectiveAccess.ts
new file mode 100644
index 0000000..c3d4f14
--- /dev/null
+++ b/src/entities/access/computeEffectiveAccess.ts
@@ -0,0 +1,147 @@
+import type { Client } from "../client/types";
+import type { Service } from "../service/types";
+import type { ClientGroup, ClientMembership, LauncherUser } from "../user/types";
+import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "./types";
+
+export function computeEffectiveAccess(input: {
+ client: Client;
+ user: LauncherUser;
+ membership: ClientMembership;
+ userGroups: ClientGroup[];
+ service: Service;
+ grants: ServiceGrant[];
+ exceptions: ServiceAccessException[];
+}): EffectiveAccessResult {
+ if (input.client.status === "suspended" || input.client.status === "expired") {
+ return blocked(input, "Клиент приостановлен или срок доступа истёк");
+ }
+
+ if (input.user.globalStatus === "blocked" || input.membership.status === "disabled") {
+ return blocked(input, "Пользователь заблокирован или отключён внутри клиента");
+ }
+
+ if (input.service.status === "disabled") {
+ return blocked(input, "Сервис отключён");
+ }
+
+ if (input.service.status === "hidden") {
+ return blocked(input, "Сервис скрыт");
+ }
+
+ const deny = input.exceptions.find(
+ (item) => item.serviceId === input.service.id && item.userId === input.user.id && item.type === "deny"
+ );
+
+ if (deny) {
+ return {
+ serviceId: input.service.id,
+ userId: input.user.id,
+ allowed: false,
+ visible: false,
+ openEnabled: false,
+ source: "exception",
+ sourceId: deny.id,
+ reason: "Доступ отключён индивидуальным исключением",
+ };
+ }
+
+ const allow = input.exceptions.find(
+ (item) => item.serviceId === input.service.id && item.userId === input.user.id && item.type === "allow"
+ );
+
+ if (allow) {
+ return {
+ serviceId: input.service.id,
+ userId: input.user.id,
+ allowed: true,
+ visible: true,
+ openEnabled: input.service.status === "active",
+ source: "exception",
+ sourceId: allow.id,
+ reason: "Доступ выдан индивидуальным allow-исключением",
+ };
+ }
+
+ const userGrant = input.grants.find(
+ (grant) =>
+ grant.serviceId === input.service.id &&
+ grant.targetType === "user" &&
+ grant.targetId === input.user.id &&
+ grant.status === "active"
+ );
+
+ if (userGrant) {
+ return {
+ serviceId: input.service.id,
+ userId: input.user.id,
+ allowed: true,
+ visible: true,
+ openEnabled: input.service.status === "active",
+ appRole: userGrant.appRole,
+ source: "user",
+ sourceId: userGrant.id,
+ reason: "Доступ выдан пользователю напрямую",
+ };
+ }
+
+ const groupIds = input.userGroups.map((group) => group.id);
+ const groupGrant = input.grants.find(
+ (grant) =>
+ grant.serviceId === input.service.id &&
+ grant.targetType === "group" &&
+ groupIds.includes(grant.targetId) &&
+ grant.status === "active"
+ );
+
+ if (groupGrant) {
+ return {
+ serviceId: input.service.id,
+ userId: input.user.id,
+ allowed: true,
+ visible: true,
+ openEnabled: input.service.status === "active",
+ appRole: groupGrant.appRole,
+ source: "group",
+ sourceId: groupGrant.id,
+ reason: "Доступ выдан группе пользователя",
+ };
+ }
+
+ const clientGrant = input.grants.find(
+ (grant) =>
+ grant.serviceId === input.service.id &&
+ grant.targetType === "client" &&
+ grant.targetId === input.client.id &&
+ grant.status === "active"
+ );
+
+ if (clientGrant) {
+ return {
+ serviceId: input.service.id,
+ userId: input.user.id,
+ allowed: true,
+ visible: true,
+ openEnabled: input.service.status === "active",
+ appRole: clientGrant.appRole,
+ source: "client",
+ sourceId: clientGrant.id,
+ reason: "Доступ выдан всему клиенту",
+ };
+ }
+
+ return blocked(input, "Доступ к сервису не выдан");
+}
+
+function blocked(input: {
+ service: Service;
+ user: LauncherUser;
+}, reason: string): EffectiveAccessResult {
+ return {
+ serviceId: input.service.id,
+ userId: input.user.id,
+ allowed: false,
+ visible: false,
+ openEnabled: false,
+ reason,
+ };
+}
diff --git a/src/entities/access/types.ts b/src/entities/access/types.ts
new file mode 100644
index 0000000..391fd96
--- /dev/null
+++ b/src/entities/access/types.ts
@@ -0,0 +1,38 @@
+export type ServiceGrantTargetType = "client" | "group" | "user";
+export type ServiceAppRole = "viewer" | "member" | "admin" | "owner";
+export type ServiceGrantStatus = "active" | "disabled";
+
+export interface ServiceGrant {
+ id: string;
+ serviceId: string;
+ targetType: ServiceGrantTargetType;
+ targetId: string;
+ appRole: ServiceAppRole;
+ status: ServiceGrantStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export type ServiceAccessExceptionType = "deny" | "allow";
+
+export interface ServiceAccessException {
+ id: string;
+ serviceId: string;
+ userId: string;
+ type: ServiceAccessExceptionType;
+ reason?: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface EffectiveAccessResult {
+ serviceId: string;
+ userId: string;
+ allowed: boolean;
+ visible: boolean;
+ openEnabled: boolean;
+ appRole?: ServiceAppRole;
+ reason: string;
+ source?: ServiceGrantTargetType | "exception";
+ sourceId?: string;
+}
diff --git a/src/entities/audit/types.ts b/src/entities/audit/types.ts
new file mode 100644
index 0000000..b1587d9
--- /dev/null
+++ b/src/entities/audit/types.ts
@@ -0,0 +1,12 @@
+export interface AuditEvent {
+ id: string;
+ at: string;
+ actorUserId: string;
+ actorName: string;
+ action: string;
+ objectType: string;
+ objectName: string;
+ clientId?: string | null;
+ result: "success" | "warning" | "error";
+ details?: string | null;
+}
diff --git a/src/entities/client/types.ts b/src/entities/client/types.ts
new file mode 100644
index 0000000..7d92873
--- /dev/null
+++ b/src/entities/client/types.ts
@@ -0,0 +1,16 @@
+export type ClientType = "company" | "person";
+export type ClientStatus = "active" | "suspended" | "demo" | "expired";
+
+export interface Client {
+ id: string;
+ type: ClientType;
+ name: string;
+ legalName?: string | null;
+ status: ClientStatus;
+ demoEndsAt?: string | null;
+ contactName?: string | null;
+ contactEmail?: string | null;
+ notes?: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/src/entities/invite/types.ts b/src/entities/invite/types.ts
new file mode 100644
index 0000000..3d3ebf6
--- /dev/null
+++ b/src/entities/invite/types.ts
@@ -0,0 +1,16 @@
+import type { ClientMembershipRole } from "../user/types";
+
+export type InviteStatus = "created" | "sent" | "accepted" | "expired" | "revoked";
+
+export interface Invite {
+ id: string;
+ clientId: string;
+ email: string;
+ role: ClientMembershipRole;
+ invitedByUserId: string;
+ token: string;
+ expiresAt: string;
+ status: InviteStatus;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/src/entities/service/types.ts b/src/entities/service/types.ts
new file mode 100644
index 0000000..8411fe5
--- /dev/null
+++ b/src/entities/service/types.ts
@@ -0,0 +1,71 @@
+import type { EffectiveAccessResult, ServiceAppRole } from "../access/types";
+
+export type ServiceStatus = "active" | "maintenance" | "hidden" | "disabled";
+export type MediaKind = "image" | "video" | "gif" | "gradient";
+export type ServiceMediaSource = "url" | "file";
+
+export interface Service {
+ id: string;
+ slug: string;
+ title: string;
+ subtitle?: string | null;
+ description: string;
+ fullDescription?: string | null;
+ url: string;
+ launchUrl?: string | null;
+ iconUrl?: string | null;
+ coverImageUrl?: string | null;
+ coverMediaKind?: MediaKind | null;
+ coverMediaSource?: ServiceMediaSource | null;
+ coverMediaFileName?: string | null;
+ previewVideoUrl?: string | null;
+ ambientVideoUrl?: string | null;
+ ambientMediaKind?: MediaKind | null;
+ ambientMediaSource?: ServiceMediaSource | null;
+ ambientMediaFileName?: string | null;
+ accentColor?: string | null;
+ fallbackGradient?: string | null;
+ status: ServiceStatus;
+ order: number;
+ authentikApplicationSlug?: string | null;
+ authentikGroupName?: string | null;
+ isAvailableForAllNewClients?: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ServiceMedia {
+ kind: MediaKind;
+ url?: string;
+ posterUrl?: string;
+ fallbackGradient?: string;
+}
+
+export interface LauncherServiceView {
+ id: string;
+ slug: string;
+ title: string;
+ subtitle?: string | null;
+ description: string;
+ fullDescription?: string | null;
+ status: ServiceStatus;
+ userAccess: "allowed" | "denied";
+ appRole?: ServiceAppRole;
+ openUrl?: string | null;
+ accentColor?: string | null;
+ media: {
+ icon?: string | null;
+ thumbnail?: string | null;
+ coverImage?: string | null;
+ coverKind?: MediaKind | null;
+ coverSource?: ServiceMediaSource | null;
+ coverFileName?: string | null;
+ previewVideo?: string | null;
+ ambientVideo?: string | null;
+ ambientKind?: MediaKind | null;
+ ambientSource?: ServiceMediaSource | null;
+ ambientFileName?: string | null;
+ fallbackGradient?: string | null;
+ };
+ effectiveAccess: EffectiveAccessResult;
+}
diff --git a/src/entities/sync/types.ts b/src/entities/sync/types.ts
new file mode 100644
index 0000000..ab75aa8
--- /dev/null
+++ b/src/entities/sync/types.ts
@@ -0,0 +1,14 @@
+export type SyncTarget = "authentik" | "task_manager" | "nodedc" | "service";
+export type SyncState = "synced" | "pending" | "error" | "disabled";
+
+export interface SyncStatus {
+ id: string;
+ objectId: string;
+ objectName: string;
+ objectType: "client" | "user" | "group" | "service" | "grant" | "invite";
+ target: SyncTarget;
+ state: SyncState;
+ lastSyncAt?: string | null;
+ error?: string | null;
+ updatedAt: string;
+}
diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts
new file mode 100644
index 0000000..a42fcd4
--- /dev/null
+++ b/src/entities/user/types.ts
@@ -0,0 +1,42 @@
+export type LauncherGlobalRole =
+ | "root_admin"
+ | "support_admin"
+ | "client_owner"
+ | "client_admin"
+ | "member";
+
+export type LauncherUserStatus = "invited" | "active" | "blocked";
+
+export interface LauncherUser {
+ id: string;
+ authentikUserId?: string | null;
+ email: string;
+ name: string;
+ avatarUrl?: string | null;
+ globalStatus: LauncherUserStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export type ClientMembershipRole = "client_owner" | "client_admin" | "member";
+export type ClientMembershipStatus = "active" | "disabled";
+
+export interface ClientMembership {
+ id: string;
+ clientId: string;
+ userId: string;
+ role: ClientMembershipRole;
+ status: ClientMembershipStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ClientGroup {
+ id: string;
+ clientId: string;
+ name: string;
+ description?: string | null;
+ memberIds: string[];
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..e0f5094
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { LauncherApp } from "./app/LauncherApp";
+import "./styles/globals.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts
new file mode 100644
index 0000000..759eb49
--- /dev/null
+++ b/src/shared/api/mockApi.ts
@@ -0,0 +1,344 @@
+import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
+import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
+import type { Client } from "../../entities/client/types";
+import type { Invite } from "../../entities/invite/types";
+import type { LauncherServiceView, Service } from "../../entities/service/types";
+import type { SyncStatus } from "../../entities/sync/types";
+import type {
+ ClientGroup,
+ ClientMembership,
+ ClientMembershipRole,
+ LauncherGlobalRole,
+ LauncherUser,
+} from "../../entities/user/types";
+import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
+import {
+ mockAuditEvents,
+ mockClients,
+ mockExceptions,
+ mockGrants,
+ mockGroups,
+ mockInvites,
+ mockMemberships,
+ mockServices,
+ mockSyncStatuses,
+ mockUsers,
+} from "./mockData";
+
+export interface AuthentikClaimsMock {
+ sub: string;
+ email: string;
+ name: string;
+ groups: string[];
+ activeClientId: string;
+}
+
+export interface MeResponse {
+ user: Pick;
+ launcherRole: LauncherGlobalRole;
+ memberships: Array<{
+ clientId: string;
+ clientName: string;
+ role: ClientMembershipRole;
+ status: ClientMembership["status"];
+ }>;
+ activeClientId: string;
+ permissions: LauncherPermissions;
+ mockAuthentikClaims: AuthentikClaimsMock;
+}
+
+export interface LauncherData {
+ clients: Client[];
+ users: LauncherUser[];
+ memberships: ClientMembership[];
+ groups: ClientGroup[];
+ services: Service[];
+ grants: ServiceGrant[];
+ exceptions: ServiceAccessException[];
+ invites: Invite[];
+ syncStatuses: SyncStatus[];
+ auditEvents: typeof mockAuditEvents;
+}
+
+export interface ProfileOption {
+ userId: string;
+ label: string;
+ description: string;
+ defaultClientId: string;
+}
+
+export interface AccessMatrixCell {
+ userId: string;
+ serviceId: string;
+ effectiveAccess: EffectiveAccessResult;
+}
+
+export interface AccessMatrix {
+ client: Client;
+ users: LauncherUser[];
+ groups: ClientGroup[];
+ services: Service[];
+ cells: AccessMatrixCell[];
+}
+
+export const initialLauncherData: LauncherData = {
+ clients: mockClients,
+ users: mockUsers,
+ memberships: mockMemberships,
+ groups: mockGroups,
+ services: mockServices,
+ grants: mockGrants,
+ exceptions: mockExceptions,
+ invites: mockInvites,
+ syncStatuses: mockSyncStatuses,
+ auditEvents: mockAuditEvents,
+};
+
+export const profileOptions: ProfileOption[] = [
+ {
+ userId: "user_root",
+ label: "Root Admin",
+ description: "Полный каталог и все клиенты",
+ defaultClientId: "client_romashka",
+ },
+ {
+ userId: "user_ivan",
+ label: "Client Owner",
+ description: "Иван, владелец Ромашки и админ демо-клиента",
+ defaultClientId: "client_romashka",
+ },
+ {
+ userId: "user_vera",
+ label: "Client Admin",
+ description: "Вера, админ ООО Ромашка",
+ defaultClientId: "client_romashka",
+ },
+ {
+ userId: "user_vasya",
+ label: "Member",
+ description: "Василий, обычный участник",
+ defaultClientId: "client_romashka",
+ },
+ {
+ userId: "user_lena",
+ label: "Member + deny",
+ description: "Лена, участник с deny-исключением",
+ defaultClientId: "client_romashka",
+ },
+ {
+ userId: "user_maria",
+ label: "Client Owner demo",
+ description: "Мария, владелец демо-клиента",
+ defaultClientId: "client_roga_kopyta",
+ },
+];
+
+export function buildMe(data: LauncherData, userId: string, requestedClientId?: string): MeResponse {
+ const user = getUser(data, userId);
+ const isRoot = user.id === "user_root";
+ const availableMemberships = isRoot
+ ? data.clients.map((client) => ({
+ clientId: client.id,
+ clientName: client.name,
+ role: "client_owner" as const,
+ status: "active" as const,
+ }))
+ : data.memberships
+ .filter((membership) => membership.userId === user.id)
+ .map((membership) => ({
+ clientId: membership.clientId,
+ clientName: getClient(data, membership.clientId).name,
+ role: membership.role,
+ status: membership.status,
+ }));
+
+ const fallbackClientId =
+ profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId;
+ const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
+ const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
+ const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);
+ const launcherRole = resolveLauncherRole({ isRoot, membershipRole: activeMembership?.role });
+ const permissions = resolvePermissions({
+ launcherRole,
+ membershipStatus: activeMembership?.status,
+ });
+
+ return {
+ user: {
+ id: user.id,
+ authentikUserId: user.authentikUserId,
+ name: user.name,
+ email: user.email,
+ avatarUrl: user.avatarUrl,
+ },
+ launcherRole,
+ memberships: availableMemberships,
+ activeClientId,
+ permissions,
+ mockAuthentikClaims: {
+ sub: user.authentikUserId ?? user.id,
+ email: user.email,
+ name: user.name,
+ groups: buildMockGroups(data, user.id, activeClientId, launcherRole),
+ activeClientId,
+ },
+ };
+}
+
+export function buildLauncherServices(data: LauncherData, userId: string, activeClientId: string): LauncherServiceView[] {
+ const me = buildMe(data, userId, activeClientId);
+ const user = getUser(data, userId);
+ const client = getClient(data, activeClientId);
+ const membership = getRuntimeMembership(data, user.id, activeClientId, me.launcherRole === "root_admin");
+ const userGroups = getUserGroups(data, user.id, activeClientId);
+ const isRoot = me.launcherRole === "root_admin";
+
+ return data.services
+ .slice()
+ .sort((a, b) => a.order - b.order)
+ .map((service) => {
+ const effectiveAccess = computeEffectiveAccess({
+ client,
+ user,
+ membership,
+ userGroups,
+ service,
+ grants: data.grants,
+ exceptions: data.exceptions,
+ });
+
+ return {
+ id: service.id,
+ slug: service.slug,
+ title: service.title,
+ subtitle: service.subtitle,
+ description: service.description,
+ fullDescription: service.fullDescription,
+ status: service.status,
+ userAccess: effectiveAccess.allowed ? ("allowed" as const) : ("denied" as const),
+ appRole: effectiveAccess.appRole,
+ openUrl: effectiveAccess.openEnabled ? service.launchUrl ?? service.url : null,
+ accentColor: service.accentColor,
+ media: {
+ icon: service.iconUrl,
+ thumbnail: service.coverImageUrl,
+ coverImage: service.coverImageUrl,
+ coverKind: service.coverMediaKind,
+ coverSource: service.coverMediaSource,
+ coverFileName: service.coverMediaFileName,
+ previewVideo: service.previewVideoUrl,
+ ambientVideo: service.ambientVideoUrl,
+ ambientKind: service.ambientMediaKind,
+ ambientSource: service.ambientMediaSource,
+ ambientFileName: service.ambientMediaFileName,
+ fallbackGradient: service.fallbackGradient,
+ },
+ effectiveAccess,
+ };
+ })
+ .filter((service) => isRoot || service.effectiveAccess.visible);
+}
+
+export function buildAccessMatrix(data: LauncherData, clientId: string, includeAllServices: boolean): AccessMatrix {
+ const client = getClient(data, clientId);
+ const memberships = data.memberships.filter((membership) => membership.clientId === clientId);
+ const users = memberships.map((membership) => getUser(data, membership.userId));
+ const groups = data.groups.filter((group) => group.clientId === clientId);
+ const clientServiceIds = new Set(
+ data.grants
+ .filter((grant) => grant.targetType === "client" && grant.targetId === clientId && grant.status === "active")
+ .map((grant) => grant.serviceId)
+ );
+ const services = data.services
+ .filter((service) => includeAllServices || clientServiceIds.has(service.id) || service.status === "active")
+ .sort((a, b) => a.order - b.order);
+ const cells = users.flatMap((user) => {
+ const membership = memberships.find((item) => item.userId === user.id) ?? getRuntimeMembership(data, user.id, clientId);
+ const userGroups = getUserGroups(data, user.id, clientId);
+
+ return services.map((service) => ({
+ userId: user.id,
+ serviceId: service.id,
+ effectiveAccess: computeEffectiveAccess({
+ client,
+ user,
+ membership,
+ userGroups,
+ service,
+ grants: data.grants,
+ exceptions: data.exceptions,
+ }),
+ }));
+ });
+
+ return { client, users, groups, services, cells };
+}
+
+export function getClient(data: LauncherData, clientId: string): Client {
+ const client = data.clients.find((item) => item.id === clientId);
+ if (!client) throw new Error(`Unknown client: ${clientId}`);
+ return client;
+}
+
+export function getUser(data: LauncherData, userId: string): LauncherUser {
+ const user = data.users.find((item) => item.id === userId);
+ if (!user) throw new Error(`Unknown user: ${userId}`);
+ return user;
+}
+
+export function getService(data: LauncherData, serviceId: string): Service {
+ const service = data.services.find((item) => item.id === serviceId);
+ if (!service) throw new Error(`Unknown service: ${serviceId}`);
+ return service;
+}
+
+export function getClientUsers(data: LauncherData, clientId: string): LauncherUser[] {
+ const userIds = new Set(data.memberships.filter((membership) => membership.clientId === clientId).map((item) => item.userId));
+ return data.users.filter((user) => userIds.has(user.id));
+}
+
+export function getUserGroups(data: LauncherData, userId: string, clientId: string): ClientGroup[] {
+ return data.groups.filter((group) => group.clientId === clientId && group.memberIds.includes(userId));
+}
+
+export function getRuntimeMembership(
+ data: LauncherData,
+ userId: string,
+ clientId: string,
+ allowVirtualRoot = false
+): ClientMembership {
+ const membership = data.memberships.find((item) => item.userId === userId && item.clientId === clientId);
+ if (membership) return membership;
+
+ if (allowVirtualRoot) {
+ return {
+ id: `virtual_root_${clientId}`,
+ clientId,
+ userId,
+ role: "client_owner",
+ status: "active",
+ createdAt: "2026-05-01T09:00:00Z",
+ updatedAt: "2026-05-01T09:00:00Z",
+ };
+ }
+
+ return {
+ id: `missing_${clientId}_${userId}`,
+ clientId,
+ userId,
+ role: "member",
+ status: "disabled",
+ createdAt: "2026-05-01T09:00:00Z",
+ updatedAt: "2026-05-01T09:00:00Z",
+ };
+}
+
+function buildMockGroups(
+ data: LauncherData,
+ userId: string,
+ activeClientId: string,
+ launcherRole: LauncherGlobalRole
+): string[] {
+ const groups = getUserGroups(data, userId, activeClientId).map((group) => `client:${activeClientId}:group:${group.name}`);
+ return [`launcher:${launcherRole}`, `client:${activeClientId}`, ...groups];
+}
diff --git a/src/shared/api/mockData.ts b/src/shared/api/mockData.ts
new file mode 100644
index 0000000..028cf67
--- /dev/null
+++ b/src/shared/api/mockData.ts
@@ -0,0 +1,448 @@
+import type { AuditEvent } from "../../entities/audit/types";
+import type { Client } from "../../entities/client/types";
+import type { Invite } from "../../entities/invite/types";
+import type { Service } from "../../entities/service/types";
+import type { SyncStatus } from "../../entities/sync/types";
+import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
+import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types";
+
+const now = "2026-05-01T09:00:00Z";
+
+export const mockClients: Client[] = [
+ {
+ id: "client_romashka",
+ type: "company",
+ name: "ООО Ромашка",
+ legalName: "ООО Ромашка",
+ status: "active",
+ demoEndsAt: null,
+ contactName: "Иван Петров",
+ contactEmail: "ivan@romashka.ru",
+ notes: "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "client_roga_kopyta",
+ type: "company",
+ name: "ООО Рога и Копыта",
+ legalName: "ООО Рога и Копыта",
+ status: "demo",
+ demoEndsAt: "2026-06-01T00:00:00Z",
+ contactName: "Мария Иванова",
+ contactEmail: "maria@example.ru",
+ notes: "Клиент на демо-доступе, подключены только базовые сервисы.",
+ createdAt: "2026-04-10T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "client_private_architect",
+ type: "person",
+ name: "Илья Архитектор",
+ legalName: null,
+ status: "suspended",
+ demoEndsAt: "2026-04-20T00:00:00Z",
+ contactName: "Илья Архитектор",
+ contactEmail: "ilya@example.ru",
+ notes: "Пример приостановленного частного клиента.",
+ createdAt: "2026-03-14T10:00:00Z",
+ updatedAt: now,
+ },
+];
+
+export const mockUsers: LauncherUser[] = [
+ {
+ id: "user_root",
+ authentikUserId: "ak-root",
+ name: "Root Admin",
+ email: "root@nodedc.local",
+ globalStatus: "active",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "user_ivan",
+ authentikUserId: "ak-ivan",
+ name: "Иван Петров",
+ email: "ivan@romashka.ru",
+ globalStatus: "active",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "user_vera",
+ authentikUserId: "ak-vera",
+ name: "Вера Соколова",
+ email: "vera@romashka.ru",
+ globalStatus: "active",
+ createdAt: "2026-04-02T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "user_vasya",
+ authentikUserId: "ak-vasya",
+ name: "Василий Орлов",
+ email: "vasya@romashka.ru",
+ globalStatus: "active",
+ createdAt: "2026-04-05T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "user_lena",
+ authentikUserId: "ak-lena",
+ name: "Лена Волкова",
+ email: "lena@romashka.ru",
+ globalStatus: "active",
+ createdAt: "2026-04-08T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "user_maria",
+ authentikUserId: "ak-maria",
+ name: "Мария Иванова",
+ email: "maria@example.ru",
+ globalStatus: "active",
+ createdAt: "2026-04-10T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "user_blocked",
+ authentikUserId: "ak-blocked",
+ name: "Олег Заблокирован",
+ email: "oleg@romashka.ru",
+ globalStatus: "blocked",
+ createdAt: "2026-04-12T10:00:00Z",
+ updatedAt: now,
+ },
+];
+
+export const mockMemberships: ClientMembership[] = [
+ membership("mem_ivan_romashka", "client_romashka", "user_ivan", "client_owner"),
+ membership("mem_vera_romashka", "client_romashka", "user_vera", "client_admin"),
+ membership("mem_vasya_romashka", "client_romashka", "user_vasya", "member"),
+ membership("mem_lena_romashka", "client_romashka", "user_lena", "member"),
+ membership("mem_blocked_romashka", "client_romashka", "user_blocked", "member", "disabled"),
+ membership("mem_maria_roga", "client_roga_kopyta", "user_maria", "client_owner"),
+ membership("mem_ivan_roga", "client_roga_kopyta", "user_ivan", "client_admin"),
+];
+
+export const mockGroups: ClientGroup[] = [
+ group("group_romashka_leads", "client_romashka", "Руководство", "Собственники и руководители клиента.", [
+ "user_ivan",
+ "user_vera",
+ ]),
+ group("group_romashka_accounting", "client_romashka", "Бухгалтерия", "1C и финансовые сценарии.", [
+ "user_lena",
+ ]),
+ group("group_romashka_ops", "client_romashka", "Операторы", "Ежедневная работа в задачах и тендерах.", [
+ "user_vasya",
+ "user_lena",
+ ]),
+ group("group_roga_demo", "client_roga_kopyta", "Демо-команда", "Пилотный контур клиента.", ["user_maria", "user_ivan"]),
+];
+
+export const mockServices: Service[] = [
+ {
+ id: "service_nodedc",
+ slug: "nodedc",
+ title: "NodeDC",
+ subtitle: "Агентная платформа",
+ description: "Сборка, запуск и мониторинг агентных workflow.",
+ fullDescription:
+ "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
+ url: "https://dev.handhdc.ru",
+ launchUrl: "https://dev.handhdc.ru/sso/launch",
+ accentColor: "#B5FF5A",
+ fallbackGradient: "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)",
+ status: "active",
+ order: 10,
+ authentikApplicationSlug: "nodedc",
+ authentikGroupName: "service-nodedc",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "service_task_manager",
+ slug: "task-manager",
+ title: "Task Manager",
+ subtitle: "Операционный слой",
+ description: "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
+ fullDescription: "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
+ url: "https://tasks.handhdc.ru",
+ launchUrl: "https://tasks.handhdc.ru/sso/launch",
+ accentColor: "#D7C8FF",
+ fallbackGradient: "linear-gradient(132deg, rgba(215, 200, 255, 0.82), rgba(51, 41, 79, 0.9) 46%, #0B0D10 84%)",
+ status: "active",
+ order: 20,
+ authentikApplicationSlug: "task-manager",
+ authentikGroupName: "service-task-manager",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "service_1c",
+ slug: "1c-assistant",
+ title: "1C Assistant",
+ subtitle: "Бухгалтерский ассистент",
+ description: "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
+ fullDescription: "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
+ url: "https://1c.handhdc.ru",
+ launchUrl: "https://1c.handhdc.ru/sso/launch",
+ accentColor: "#8FD7FF",
+ fallbackGradient: "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)",
+ status: "maintenance",
+ order: 30,
+ authentikApplicationSlug: "1c-assistant",
+ authentikGroupName: "service-1c-assistant",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "service_tender",
+ slug: "tender-agent",
+ title: "Tender Agent",
+ subtitle: "Госзакупки и тендеры",
+ description: "Поиск, анализ и подготовка тендерных решений.",
+ fullDescription: "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
+ url: "https://tender.handhdc.ru",
+ launchUrl: "https://tender.handhdc.ru/sso/launch",
+ accentColor: "#FFD166",
+ fallbackGradient: "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)",
+ status: "active",
+ order: 40,
+ authentikApplicationSlug: "tender-agent",
+ authentikGroupName: "service-tender-agent",
+ createdAt: "2026-04-03T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "service_digital_twin",
+ slug: "digital-twin",
+ title: "Digital Twin",
+ subtitle: "3D и пространственные данные",
+ description: "Просмотр цифровых двойников, карт и объектных сцен.",
+ fullDescription: "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
+ url: "https://twin.handhdc.ru",
+ launchUrl: "https://twin.handhdc.ru/sso/launch",
+ accentColor: "#76E4F7",
+ fallbackGradient: "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
+ status: "active",
+ order: 50,
+ authentikApplicationSlug: "digital-twin",
+ authentikGroupName: "service-digital-twin",
+ createdAt: "2026-04-05T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "service_dm",
+ slug: "digital-modules",
+ title: "Digital Modules",
+ subtitle: "Будущие модули",
+ description: "Скрытый каталог модулей для root-admin preview.",
+ fullDescription: "Площадка для будущих цифровых модулей NODE.DC.",
+ url: "https://dm.handhdc.ru",
+ launchUrl: "https://dm.handhdc.ru/sso/launch",
+ accentColor: "#FF9AC2",
+ fallbackGradient: "linear-gradient(135deg, rgba(255, 154, 194, 0.78), rgba(76, 41, 64, 0.9) 44%, #090B0F 86%)",
+ status: "hidden",
+ order: 60,
+ authentikApplicationSlug: "digital-modules",
+ authentikGroupName: "service-digital-modules",
+ createdAt: "2026-04-10T10:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "service_internal",
+ slug: "internal-tools",
+ title: "Internal Tools",
+ subtitle: "Внутренний контур",
+ description: "Отключённый сервис для проверки диагностики root-admin.",
+ fullDescription: "Не показывается обычным пользователям, виден root-admin в каталоге.",
+ url: "https://internal.handhdc.ru",
+ launchUrl: null,
+ accentColor: "#F97373",
+ fallbackGradient: "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
+ status: "disabled",
+ order: 70,
+ authentikApplicationSlug: "internal-tools",
+ authentikGroupName: "service-internal-tools",
+ createdAt: "2026-04-12T10:00:00Z",
+ updatedAt: now,
+ },
+];
+
+export const mockGrants: ServiceGrant[] = [
+ grant("grant_romashka_task", "service_task_manager", "client", "client_romashka", "member"),
+ grant("grant_romashka_nodedc_leads", "service_nodedc", "group", "group_romashka_leads", "admin"),
+ grant("grant_romashka_1c_accounting", "service_1c", "group", "group_romashka_accounting", "member"),
+ grant("grant_romashka_tender_ops", "service_tender", "group", "group_romashka_ops", "viewer"),
+ grant("grant_romashka_twin_vasya", "service_digital_twin", "user", "user_vasya", "viewer"),
+ grant("grant_roga_task", "service_task_manager", "client", "client_roga_kopyta", "member"),
+ grant("grant_roga_nodedc", "service_nodedc", "client", "client_roga_kopyta", "viewer"),
+];
+
+export const mockExceptions: ServiceAccessException[] = [
+ {
+ id: "exception_lena_task_deny",
+ serviceId: "service_task_manager",
+ userId: "user_lena",
+ type: "deny",
+ reason: "Индивидуально отключён Task Manager на период ревизии доступа.",
+ createdAt: "2026-04-28T10:00:00Z",
+ updatedAt: now,
+ },
+];
+
+export const mockInvites: Invite[] = [
+ {
+ id: "invite_romashka_analyst",
+ clientId: "client_romashka",
+ email: "analyst@romashka.ru",
+ role: "member",
+ invitedByUserId: "user_ivan",
+ token: "romashka-analyst-demo",
+ expiresAt: "2026-05-15T12:00:00Z",
+ status: "sent",
+ createdAt: "2026-04-30T12:00:00Z",
+ updatedAt: now,
+ },
+ {
+ id: "invite_roga_admin",
+ clientId: "client_roga_kopyta",
+ email: "ops@example.ru",
+ role: "client_admin",
+ invitedByUserId: "user_maria",
+ token: "roga-admin-demo",
+ expiresAt: "2026-05-18T12:00:00Z",
+ status: "created",
+ createdAt: "2026-04-30T14:00:00Z",
+ updatedAt: now,
+ },
+];
+
+export const mockSyncStatuses: SyncStatus[] = [
+ sync("sync_romashka_auth", "client_romashka", "ООО Ромашка", "client", "authentik", "synced"),
+ sync("sync_task_auth", "service_task_manager", "Task Manager", "service", "authentik", "synced"),
+ sync(
+ "sync_lena_task",
+ "exception_lena_task_deny",
+ "Deny: Лена / Task Manager",
+ "grant",
+ "task_manager",
+ "pending",
+ null
+ ),
+ sync(
+ "sync_roga_nodedc",
+ "client_roga_kopyta",
+ "ООО Рога и Копыта",
+ "client",
+ "nodedc",
+ "error",
+ "OIDC binding ещё не создан для demo-клиента."
+ ),
+];
+
+export const mockAuditEvents: AuditEvent[] = [
+ audit("audit_1", "2026-05-01T08:40:00Z", "user_root", "Root Admin", "Создан сервис", "service", "Digital Modules", "success", null),
+ audit("audit_2", "2026-05-01T08:20:00Z", "user_ivan", "Иван Петров", "Создан invite", "invite", "analyst@romashka.ru", "success", "Срок действия до 15.05.2026"),
+ audit("audit_3", "2026-04-30T17:10:00Z", "user_root", "Root Admin", "Создано deny-исключение", "access", "Лена / Task Manager", "warning", "Индивидуальное правило перекрыло client grant."),
+ audit("audit_4", "2026-04-30T16:00:00Z", "user_root", "Root Admin", "Ошибка синхронизации", "sync", "ООО Рога и Копыта / NodeDC", "error", "Нет application binding."),
+];
+
+function membership(
+ id: string,
+ clientId: string,
+ userId: string,
+ role: ClientMembership["role"],
+ status: ClientMembership["status"] = "active"
+): ClientMembership {
+ return {
+ id,
+ clientId,
+ userId,
+ role,
+ status,
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ };
+}
+
+function group(id: string, clientId: string, name: string, description: string, memberIds: string[]): ClientGroup {
+ return {
+ id,
+ clientId,
+ name,
+ description,
+ memberIds,
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ };
+}
+
+function grant(
+ id: string,
+ serviceId: string,
+ targetType: ServiceGrant["targetType"],
+ targetId: string,
+ appRole: ServiceGrant["appRole"]
+): ServiceGrant {
+ return {
+ id,
+ serviceId,
+ targetType,
+ targetId,
+ appRole,
+ status: "active",
+ createdAt: "2026-04-01T10:00:00Z",
+ updatedAt: now,
+ };
+}
+
+function sync(
+ id: string,
+ objectId: string,
+ objectName: string,
+ objectType: SyncStatus["objectType"],
+ target: SyncStatus["target"],
+ state: SyncStatus["state"],
+ error: string | null = null
+): SyncStatus {
+ return {
+ id,
+ objectId,
+ objectName,
+ objectType,
+ target,
+ state,
+ lastSyncAt: state === "pending" ? null : "2026-05-01T08:00:00Z",
+ error,
+ updatedAt: now,
+ };
+}
+
+function audit(
+ id: string,
+ at: string,
+ actorUserId: string,
+ actorName: string,
+ action: string,
+ objectType: string,
+ objectName: string,
+ result: AuditEvent["result"],
+ details: string | null,
+ clientId: string | null = "client_romashka"
+): AuditEvent {
+ return {
+ id,
+ at,
+ actorUserId,
+ actorName,
+ action,
+ objectType,
+ objectName,
+ clientId,
+ result,
+ details,
+ };
+}
diff --git a/src/shared/api/storageApi.ts b/src/shared/api/storageApi.ts
new file mode 100644
index 0000000..e26388f
--- /dev/null
+++ b/src/shared/api/storageApi.ts
@@ -0,0 +1,72 @@
+import type { LauncherData } from "./mockApi";
+
+export interface StoredFileResponse {
+ ok: true;
+ url: string;
+ fileName: string;
+ originalFileName: string;
+ mimeType: string;
+}
+
+export async function uploadStorageFile(file: File): Promise {
+ const dataUrl = await readFileAsDataUrl(file);
+ const response = await fetch("/api/storage/upload", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ fileName: file.name,
+ mimeType: file.type || "application/octet-stream",
+ dataUrl,
+ }),
+ });
+
+ if (!response.ok) {
+ const message = await readErrorMessage(response);
+ throw new Error(message || "Не удалось сохранить файл в storage");
+ }
+
+ return response.json() as Promise;
+}
+
+export async function loadPersistedLauncherData(): Promise {
+ try {
+ const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" });
+
+ if (!response.ok) return null;
+
+ return (await response.json()) as LauncherData;
+ } catch {
+ return null;
+ }
+}
+
+export async function persistLauncherData(data: LauncherData): Promise {
+ const response = await fetch("/api/storage/data", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const message = await readErrorMessage(response);
+ throw new Error(message || "Не удалось сохранить состояние витрины");
+ }
+}
+
+function readFileAsDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("load", () => resolve(String(reader.result)));
+ reader.addEventListener("error", () => reject(reader.error ?? new Error("Не удалось прочитать файл")));
+ reader.readAsDataURL(file);
+ });
+}
+
+async function readErrorMessage(response: Response) {
+ try {
+ const payload = (await response.json()) as { error?: string };
+ return payload.error;
+ } catch {
+ return response.statusText;
+ }
+}
diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts
new file mode 100644
index 0000000..4aa9305
--- /dev/null
+++ b/src/shared/lib/cn.ts
@@ -0,0 +1,3 @@
+export function cn(...values: Array): string {
+ return values.filter(Boolean).join(" ");
+}
diff --git a/src/shared/lib/format.ts b/src/shared/lib/format.ts
new file mode 100644
index 0000000..c5b06b8
--- /dev/null
+++ b/src/shared/lib/format.ts
@@ -0,0 +1,28 @@
+export function formatDate(value?: string | null): string {
+ if (!value) return "—";
+ return new Intl.DateTimeFormat("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ }).format(new Date(value));
+}
+
+export function formatDateTime(value?: string | null): string {
+ if (!value) return "—";
+ return new Intl.DateTimeFormat("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(value));
+}
+
+export function initials(name: string): string {
+ return name
+ .split(" ")
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("");
+}
diff --git a/src/shared/lib/permissions.ts b/src/shared/lib/permissions.ts
new file mode 100644
index 0000000..9132878
--- /dev/null
+++ b/src/shared/lib/permissions.ts
@@ -0,0 +1,43 @@
+import type { ClientMembershipRole, ClientMembershipStatus, LauncherGlobalRole } from "../../entities/user/types";
+
+export interface LauncherPermissions {
+ canOpenAdmin: boolean;
+ canManageClients: boolean;
+ canManageOwnClient: boolean;
+ canManageServiceCatalog: boolean;
+ canInviteUsers: boolean;
+ canManageAccess: boolean;
+ canViewSync: boolean;
+}
+
+export function resolveLauncherRole(input: {
+ isRoot?: boolean;
+ membershipRole?: ClientMembershipRole;
+}): LauncherGlobalRole {
+ if (input.isRoot) return "root_admin";
+ return input.membershipRole ?? "member";
+}
+
+export function resolvePermissions(input: {
+ launcherRole: LauncherGlobalRole;
+ membershipStatus?: ClientMembershipStatus;
+}): LauncherPermissions {
+ const active = input.membershipStatus !== "disabled";
+ const isRoot = input.launcherRole === "root_admin";
+ const isSupport = input.launcherRole === "support_admin";
+ const isClientAdmin = input.launcherRole === "client_owner" || input.launcherRole === "client_admin";
+
+ return {
+ canOpenAdmin: active && (isRoot || isSupport || isClientAdmin),
+ canManageClients: active && isRoot,
+ canManageOwnClient: active && (isRoot || isClientAdmin),
+ canManageServiceCatalog: active && isRoot,
+ canInviteUsers: active && (isRoot || isClientAdmin),
+ canManageAccess: active && (isRoot || isClientAdmin),
+ canViewSync: active && (isRoot || isSupport || isClientAdmin),
+ };
+}
+
+export function isClientAdminRole(role: LauncherGlobalRole): boolean {
+ return role === "client_owner" || role === "client_admin";
+}
diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx
new file mode 100644
index 0000000..77b9e52
--- /dev/null
+++ b/src/shared/ui/Button.tsx
@@ -0,0 +1,30 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import { cn } from "../lib/cn";
+
+type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ icon?: ReactNode;
+}
+
+export function Button({ variant = "secondary", icon, className, children, ...props }: ButtonProps) {
+ return (
+
+ {icon}
+ {children ? {children} : null}
+
+ );
+}
+
+interface IconButtonProps extends ButtonHTMLAttributes {
+ label: string;
+}
+
+export function IconButton({ label, className, children, ...props }: IconButtonProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/Glass.tsx b/src/shared/ui/Glass.tsx
new file mode 100644
index 0000000..f3cb21b
--- /dev/null
+++ b/src/shared/ui/Glass.tsx
@@ -0,0 +1,23 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { cn } from "../lib/cn";
+
+interface GlassProps extends HTMLAttributes {
+ children: ReactNode;
+ tone?: "default" | "strong" | "soft";
+}
+
+export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function GlassCard({ children, className, tone = "default", ...props }: GlassProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/PortalDropdown.tsx b/src/shared/ui/PortalDropdown.tsx
new file mode 100644
index 0000000..6acd21b
--- /dev/null
+++ b/src/shared/ui/PortalDropdown.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { GlassSurface } from "./Glass";
+
+export function PortalDropdown({
+ open,
+ children,
+ style,
+}: {
+ open: boolean;
+ children: ReactNode;
+ style?: React.CSSProperties;
+}) {
+ if (!open || typeof document === "undefined") return null;
+
+ return createPortal(
+
+ {children}
+ ,
+ document.body
+ );
+}
diff --git a/src/shared/ui/StatusBadge.tsx b/src/shared/ui/StatusBadge.tsx
new file mode 100644
index 0000000..b7a85db
--- /dev/null
+++ b/src/shared/ui/StatusBadge.tsx
@@ -0,0 +1,82 @@
+import type { ClientStatus } from "../../entities/client/types";
+import type { ServiceStatus } from "../../entities/service/types";
+import type { SyncState } from "../../entities/sync/types";
+import type { LauncherUserStatus } from "../../entities/user/types";
+import { cn } from "../lib/cn";
+
+type BadgeTone = "green" | "yellow" | "red" | "violet" | "muted";
+
+const serviceLabels: Record = {
+ active: "Активен",
+ maintenance: "Техработы",
+ hidden: "Скрыт",
+ disabled: "Отключён",
+};
+
+const clientLabels: Record = {
+ active: "Активен",
+ demo: "Demo",
+ suspended: "Приостановлен",
+ expired: "Истёк",
+};
+
+const userLabels: Record = {
+ active: "Активен",
+ invited: "Приглашён",
+ blocked: "Заблокирован",
+};
+
+const syncLabels: Record = {
+ synced: "Синхронизировано",
+ pending: "В очереди",
+ error: "Ошибка",
+ disabled: "Отключено",
+};
+
+export function StatusBadge({
+ label,
+ tone = "muted",
+ className,
+}: {
+ label: string;
+ tone?: BadgeTone;
+ className?: string;
+}) {
+ return {label} ;
+}
+
+export function ServiceStatusBadge({ status }: { status: ServiceStatus }) {
+ return ;
+}
+
+export function ClientStatusBadge({ status }: { status: ClientStatus }) {
+ return ;
+}
+
+export function UserStatusBadge({ status }: { status: LauncherUserStatus }) {
+ return ;
+}
+
+export function SyncStatusBadge({ state }: { state: SyncState }) {
+ return ;
+}
+
+function statusTone(status: ServiceStatus): BadgeTone {
+ if (status === "active") return "green";
+ if (status === "maintenance") return "yellow";
+ if (status === "hidden") return "violet";
+ return "red";
+}
+
+function clientTone(status: ClientStatus): BadgeTone {
+ if (status === "active") return "green";
+ if (status === "demo") return "yellow";
+ return "red";
+}
+
+function syncTone(state: SyncState): BadgeTone {
+ if (state === "synced") return "green";
+ if (state === "pending") return "yellow";
+ if (state === "error") return "red";
+ return "muted";
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..39185a2
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,2209 @@
+:root {
+ color-scheme: dark;
+ --nodedc-accent-rgb: 195 255 102;
+ --nodedc-card-passive-rgb: 42 43 46;
+ --nodedc-card-active-rgb: 195 255 102;
+ --nodedc-on-accent-rgb: 11 17 23;
+ --launcher-radius-modal: 1.75rem;
+ --launcher-radius-card: 1.35rem;
+ --launcher-radius-control: 1.25rem;
+ --launcher-radius-circle: 999px;
+ --surface-base: rgba(8, 8, 11, 0.78);
+ --surface-strong: rgba(8, 8, 11, 0.9);
+ --surface-soft: rgba(255, 255, 255, 0.08);
+ --border-soft: transparent;
+ --text-primary: #f7f8f4;
+ --text-secondary: rgba(247, 248, 244, 0.72);
+ --text-muted: rgba(247, 248, 244, 0.48);
+ --danger: #ff7474;
+ --warning: #ffd166;
+ --cyan: #76e4f7;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ min-height: 100%;
+}
+
+body {
+ margin: 0;
+ color: var(--text-primary);
+ background: #050506;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button,
+select,
+input {
+ outline: none;
+}
+
+button:focus-visible,
+select:focus-visible,
+input:focus-visible {
+ background-color: rgba(255, 255, 255, 0.1);
+ box-shadow: none;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: not-allowed;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 0.85rem 0.9rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ color: var(--text-muted);
+ font-size: 0.74rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+td small {
+ display: block;
+ margin-top: 0.25rem;
+ color: var(--text-muted);
+}
+
+code {
+ display: inline-flex;
+ max-width: 18rem;
+ overflow: hidden;
+ padding: 0.28rem 0.5rem;
+ border-radius: 0.65rem;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-secondary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.launcher-app {
+ position: relative;
+ min-height: 100vh;
+ overflow: hidden;
+ padding: 0;
+ background: #050506;
+}
+
+.eyebrow {
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.select-shell {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ height: 2.85rem;
+ min-width: 11rem;
+ padding: 0 0.95rem;
+ border: 0;
+ border-radius: var(--launcher-radius-circle);
+ background: transparent;
+ color: var(--text-secondary);
+ transition:
+ background-color 160ms ease,
+ color 160ms ease;
+}
+
+.select-shell:hover {
+ background: rgba(255, 255, 255, 0.045);
+ color: var(--text-primary);
+}
+
+.select-shell--wide {
+ width: 100%;
+}
+
+.select-shell select,
+.invite-form select {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: var(--text-primary);
+}
+
+.select-shell option,
+.invite-form option {
+ color: #101318;
+}
+
+.nodedc-expanded-toolbar-shell {
+ position: relative;
+ z-index: 80;
+ width: 100%;
+ flex-shrink: 0;
+ padding: 1rem 1.25rem 0.75rem;
+}
+
+.nodedc-expanded-toolbar {
+ display: flex;
+ min-height: 4.25rem;
+ width: 100%;
+ flex-direction: column;
+ gap: 0;
+}
+
+.nodedc-expanded-toolbar-top {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ min-height: 3rem;
+ width: 100%;
+ align-items: center;
+ gap: 1rem;
+}
+
+.nodedc-expanded-toolbar-left,
+.nodedc-expanded-toolbar-center,
+.nodedc-expanded-toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.nodedc-expanded-toolbar-left {
+ justify-content: flex-start;
+}
+
+.nodedc-expanded-toolbar-center {
+ justify-content: center;
+}
+
+.nodedc-expanded-toolbar-right {
+ justify-content: flex-end;
+}
+
+.nodedc-expanded-brand-logo {
+ display: block;
+ width: 7.25rem;
+ height: auto;
+ max-height: 2.2rem;
+ object-fit: contain;
+}
+
+.nodedc-expanded-workspace-button {
+ position: relative;
+ display: flex;
+ width: 3rem;
+ height: 3rem;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.04);
+ backdrop-filter: blur(18px);
+ -webkit-backdrop-filter: blur(18px);
+ transition: background-color 160ms ease;
+}
+
+.nodedc-expanded-workspace-button:hover {
+ background: rgba(255, 255, 255, 0.07);
+}
+
+.nodedc-expanded-workspace-button select,
+.nodedc-expanded-select-button select {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.nodedc-expanded-workspace-button option,
+.nodedc-expanded-select-button option {
+ color: #101318;
+}
+
+.nodedc-expanded-workspace-mark {
+ width: 1.75rem;
+ height: 1.75rem;
+ object-fit: contain;
+}
+
+.nodedc-expanded-user-group {
+ display: inline-flex;
+ height: 3.45rem;
+ min-height: 3.45rem;
+ align-items: center;
+ gap: 0.22rem;
+ border-radius: 999px;
+ background: rgba(64, 64, 64, 0.48);
+ padding: 0.32rem;
+}
+
+.nodedc-expanded-user-group .nodedc-expanded-nav-button {
+ min-height: 2.78rem;
+ padding-inline: 1.2rem;
+}
+
+.nodedc-expanded-user-group .nodedc-expanded-nav-button:not([data-active="true"]) {
+ color: rgba(255, 255, 255, 0.68);
+}
+
+.nodedc-expanded-nav-group {
+ display: inline-flex;
+ min-height: 3.45rem;
+ align-items: center;
+ gap: 0.18rem;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(64, 64, 64, 0.48);
+ padding: 0.32rem;
+ box-shadow: none;
+}
+
+.nodedc-expanded-nav-button {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0;
+ min-height: 2.78rem;
+ border: 0;
+ outline: none;
+ box-shadow: none;
+ border-radius: 999px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.68);
+ padding: 0.2rem 1.22rem;
+ font-size: 0.74rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0;
+ white-space: nowrap;
+ transition:
+ background-color 160ms ease,
+ color 160ms ease,
+ opacity 160ms ease;
+}
+
+.nodedc-expanded-nav-button:hover {
+ background: rgba(255, 255, 255, 0.07);
+ color: rgba(255, 255, 255, 0.96);
+}
+
+.nodedc-expanded-nav-button[data-active="true"] {
+ background: rgba(255, 255, 255, 0.92);
+ color: rgba(8, 8, 10, 0.96);
+}
+
+.nodedc-toolbar-icon-button {
+ border: 0;
+ outline: none;
+ box-shadow: none;
+ border-radius: 999px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.72);
+ transition:
+ color 160ms ease,
+ background-color 160ms ease,
+ transform 160ms ease;
+}
+
+.nodedc-toolbar-icon-button:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.94);
+}
+
+.nodedc-toolbar-icon-button[data-active="true"] {
+ background: transparent;
+ color: rgba(255, 255, 255, 0.98);
+}
+
+.nodedc-toolbar-icon-active-dot {
+ display: grid;
+ height: 2rem;
+ width: 2rem;
+ place-items: center;
+ border-radius: 999px;
+ transition:
+ background-color 160ms ease,
+ color 160ms ease;
+}
+
+.nodedc-toolbar-icon-button[data-active="true"] .nodedc-toolbar-icon-active-dot {
+ background: rgb(var(--nodedc-accent-rgb));
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.nodedc-expanded-notification-button {
+ height: 2.78rem;
+ width: 2.78rem;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.68);
+}
+
+.nodedc-expanded-notification-button .nodedc-toolbar-icon-active-dot {
+ height: auto;
+ width: auto;
+ color: rgba(255, 255, 255, 0.68);
+}
+
+.nodedc-expanded-notification-button:hover,
+.nodedc-expanded-notification-button:hover .nodedc-toolbar-icon-active-dot {
+ color: rgba(255, 255, 255, 0.94);
+}
+
+.nodedc-expanded-user-avatar-button {
+ display: flex;
+ width: 3rem;
+ height: 3rem;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ padding: 0;
+ transition: background-color 160ms ease;
+}
+
+.nodedc-expanded-user-avatar-button:hover {
+ background: transparent;
+}
+
+.nodedc-expanded-user-avatar {
+ display: grid;
+ width: 3rem;
+ height: 3rem;
+ place-items: center;
+ border-radius: 999px;
+ background:
+ radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.42), transparent 24%),
+ linear-gradient(135deg, rgba(195, 255, 102, 0.52), rgba(170, 120, 170, 0.72));
+ color: rgba(8, 8, 10, 0.96);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.launcher-main {
+ --launcher-page-pad: 1.25rem;
+ --launcher-rail-bottom: 1.2rem;
+ --launcher-rail-height: 9.2rem;
+ --launcher-stage-rail-gap: 1.2rem;
+ --admin-panel-gap: var(--launcher-page-pad);
+ --admin-nav-width: clamp(20.75rem, 19.5vw, 22rem);
+ --admin-content-width: calc(
+ 100vw - (var(--launcher-page-pad) * 2) - (var(--admin-panel-gap) * 2) - (var(--admin-nav-width) * 2)
+ );
+ --admin-nav-pad: 1.1rem;
+ --admin-control-ring: 2.92rem;
+ --admin-control-inset: 5px;
+ position: relative;
+ display: grid;
+ min-height: calc(100vh - 6rem);
+ margin-top: 0;
+ overflow: hidden;
+ border: 0;
+ border-radius: 0;
+ background: #050506;
+}
+
+.launcher-main:has(.admin-panel-layer) .service-stage {
+ padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap));
+}
+
+.launcher-main:has(.admin-panel-layer--content-open) .service-stage {
+ padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-content-width) + (var(--admin-panel-gap) * 2));
+}
+
+.launcher-main:has(.admin-panel-layer--content-open) .stage-video-topline,
+.launcher-main:has(.admin-panel-layer--content-open) .stage-side-controls,
+.launcher-main:has(.admin-panel-layer--content-open) .stage-service-overlay,
+.launcher-main:has(.admin-panel-layer--content-open) .stage-video-controls,
+.launcher-main:has(.admin-panel-layer--content-open) .stage-timeline-strip {
+ display: none;
+}
+
+.service-stage {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ align-items: stretch;
+ justify-items: stretch;
+ padding: 0 var(--launcher-page-pad)
+ calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap))
+ var(--launcher-page-pad);
+ overflow: hidden;
+ background: #050506;
+ transition:
+ padding-left 440ms cubic-bezier(0.22, 1, 0.36, 1),
+ background 220ms ease;
+}
+
+.service-stage--empty {
+ background: #050506;
+}
+
+.stage-video-shell,
+.empty-stage-card {
+ border: 0;
+ overflow: hidden;
+ border-radius: var(--launcher-radius-card);
+}
+
+.stage-video-shell {
+ position: relative;
+ z-index: 2;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.012)),
+ rgba(20, 20, 25, 0.64);
+ box-shadow: 0 44px 150px rgba(0, 0, 0, 0.6);
+ isolation: isolate;
+}
+
+.stage-video-stream {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ background: #050506;
+}
+
+.stage-video-gif {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 0.92;
+}
+
+.stage-video-stream::before {
+ position: absolute;
+ inset: -20%;
+ background:
+ linear-gradient(90deg, transparent 0 12%, rgba(255, 255, 255, 0.07) 12% 13%, transparent 13% 27%),
+ linear-gradient(180deg, transparent 0 18%, rgba(255, 255, 255, 0.05) 18% 19%, transparent 19% 32%);
+ background-size: 7rem 7rem;
+ content: "";
+ opacity: 0.34;
+ transform: rotate(-2deg);
+ animation: stageVideoDrift 16s linear infinite;
+ mix-blend-mode: screen;
+}
+
+.stage-video-stream::after {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at 50% 50%, transparent 0 52%, rgba(0, 0, 0, 0.28) 100%),
+ linear-gradient(90deg, rgba(0, 0, 0, 0.28), transparent 18% 78%, rgba(0, 0, 0, 0.22));
+ content: "";
+}
+
+.stage-video-topline {
+ position: absolute;
+ z-index: 3;
+ top: 1.05rem;
+ left: 1.05rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.6rem;
+ color: rgba(255, 255, 255, 0.86);
+ font-size: 0.95rem;
+ font-weight: 700;
+}
+
+.stage-round-button,
+.stage-side-controls span,
+.stage-video-controls button {
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.16);
+ color: rgba(255, 255, 255, 0.88);
+ backdrop-filter: blur(18px);
+ -webkit-backdrop-filter: blur(18px);
+}
+
+.stage-round-button {
+ width: 2rem;
+ height: 2rem;
+}
+
+.stage-side-controls {
+ position: absolute;
+ z-index: 3;
+ left: 1.05rem;
+ top: 31%;
+ display: grid;
+ gap: 0.62rem;
+}
+
+.stage-side-controls span {
+ width: 2rem;
+ height: 2rem;
+}
+
+.stage-service-overlay {
+ position: absolute;
+ z-index: 4;
+ top: 50%;
+ left: 50%;
+ display: grid;
+ width: min(73rem, calc(100% - 12rem));
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ align-items: center;
+ justify-content: center;
+ transform: translate(-50%, -50%);
+}
+
+.stage-image-card,
+.stage-description-card {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ overflow: hidden;
+ border: 0;
+ border-radius: 1.55rem;
+}
+
+.stage-image-card {
+ padding: 0;
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.12)),
+ linear-gradient(130deg, color-mix(in srgb, var(--service-accent) 38%, rgba(216, 170, 255, 0.58)), rgba(255, 236, 184, 0.6));
+ box-shadow: 0 26px 80px rgba(0, 0, 0, 0.22);
+}
+
+.stage-card-label {
+ position: relative;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.stage-image-card__visual {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ border-radius: inherit;
+}
+
+.stage-image-card__image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: inherit;
+}
+
+.stage-image-card__figure {
+ position: absolute;
+ left: 28%;
+ top: 4%;
+ width: 13rem;
+ height: 19rem;
+ border-radius: 48% 52% 44% 56%;
+ background:
+ radial-gradient(circle at 45% 18%, rgba(255, 245, 210, 0.82), transparent 22%),
+ linear-gradient(160deg, rgba(163, 93, 255, 0.46), rgba(255, 190, 24, 0.78), rgba(255, 255, 255, 0.34));
+ filter: blur(10px);
+ transform: rotate(-7deg);
+ animation: stageFigureFloat 5.5s ease-in-out infinite alternate;
+}
+
+.stage-image-card__glow {
+ position: absolute;
+ inset: 14% 7% 10% 12%;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.25);
+ filter: blur(24px);
+}
+
+.stage-image-card__time {
+ position: absolute;
+ z-index: 2;
+ bottom: 1rem;
+ left: 1rem;
+ display: inline-flex;
+ min-height: 1.85rem;
+ align-items: center;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.18);
+ color: rgba(255, 255, 255, 0.86);
+ padding: 0 0.65rem;
+ font-size: 0.72rem;
+}
+
+.stage-description-card {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem;
+ background:
+ linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.035)),
+ rgba(38, 38, 42, 0.34);
+ box-shadow:
+ 0 26px 80px rgba(0, 0, 0, 0.28),
+ inset 0 1px 0 rgba(255, 255, 255, 0.07);
+ backdrop-filter: blur(30px) saturate(1.25);
+ -webkit-backdrop-filter: blur(30px) saturate(1.25);
+}
+
+.stage-description-card__copy {
+ display: grid;
+ gap: 0.68rem;
+}
+
+.stage-description-card__copy h1 {
+ margin: 0;
+ font-size: clamp(1.65rem, 2vw, 2.25rem);
+ line-height: 0.98;
+}
+
+.stage-description-card__copy p {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 0.86rem;
+ line-height: 1.48;
+}
+
+.stage-rich-description {
+ display: grid;
+ gap: 0.75rem;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 0.86rem;
+ line-height: 1.48;
+}
+
+.stage-rich-description p,
+.stage-rich-description ul {
+ margin: 0;
+}
+
+.stage-rich-description ul {
+ display: grid;
+ gap: 0.36rem;
+ padding-left: 1.1rem;
+}
+
+.stage-rich-description strong {
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 850;
+}
+
+.stage-rich-description em {
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.stage-rich-description code {
+ display: inline-flex;
+ max-width: 100%;
+ padding: 0.08rem 0.32rem;
+ border-radius: 0.45rem;
+ background: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.86);
+ font-size: 0.78rem;
+}
+
+.stage-description-card__chips,
+.stage-description-card__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.stage-description-card__chips .status-badge,
+.stage-description-card__actions .button {
+ min-height: 2.78rem;
+ border-radius: var(--launcher-radius-circle);
+ padding: 0 1.22rem;
+ font-size: 0.74rem;
+ font-weight: 800;
+}
+
+.stage-description-card__actions {
+ align-self: flex-end;
+ justify-content: flex-end;
+ margin-top: auto;
+}
+
+.stage-description-card__reason {
+ display: grid;
+ gap: 0.3rem;
+ padding: 0.78rem;
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.stage-description-card__reason span {
+ color: rgba(255, 255, 255, 0.48);
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.stage-description-card__reason strong {
+ color: rgba(255, 255, 255, 0.76);
+ font-size: 0.86rem;
+ line-height: 1.38;
+}
+
+.stage-video-controls {
+ position: absolute;
+ z-index: 3;
+ bottom: 1.15rem;
+ left: 50%;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.32rem;
+ transform: translateX(-50%);
+}
+
+.stage-video-controls button {
+ width: 2.15rem;
+ height: 2.15rem;
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.stage-timeline-strip {
+ display: none;
+ position: absolute;
+ right: 9%;
+ bottom: 0;
+ left: 20%;
+ z-index: 2;
+ display: grid;
+ grid-template-columns: 1fr 0.72fr 0.58fr 0.82fr 0.64fr;
+ gap: 0.28rem;
+ height: 4.2rem;
+ opacity: 0.46;
+ transform: translateY(45%);
+}
+
+.stage-timeline-strip span {
+ border-radius: 0.65rem 0.65rem 0 0;
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.stage-timeline-strip span:nth-child(3) {
+ background: color-mix(in srgb, var(--service-accent) 45%, rgba(255, 255, 255, 0.16));
+}
+
+@keyframes stageVideoDrift {
+ from {
+ transform: translate3d(0, 0, 0) rotate(-2deg);
+ }
+ to {
+ transform: translate3d(-7rem, -7rem, 0) rotate(-2deg);
+ }
+}
+
+@keyframes stageFigureFloat {
+ from {
+ transform: translate3d(-0.8rem, 0, 0) rotate(-9deg) scale(1);
+ }
+ to {
+ transform: translate3d(0.8rem, -0.3rem, 0) rotate(-3deg) scale(1.05);
+ }
+}
+
+.glass-surface,
+.glass-card {
+ border: 0;
+ background: var(--surface-base);
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(28px);
+ -webkit-backdrop-filter: blur(28px);
+}
+
+.glass-surface {
+ border-radius: var(--launcher-radius-card);
+}
+
+.glass-card {
+ border-radius: 1rem;
+}
+
+.glass-surface--strong {
+ background: var(--surface-strong);
+}
+
+.glass-surface--soft {
+ background: var(--surface-soft);
+}
+
+.service-rail {
+ position: absolute;
+ z-index: 5;
+ right: var(--launcher-page-pad);
+ bottom: var(--launcher-rail-bottom);
+ left: var(--launcher-page-pad);
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ overflow-x: auto;
+ padding: 0.7rem;
+ border: 0;
+ border-radius: var(--launcher-radius-card);
+ background: rgba(8, 8, 11, 0.86);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ scrollbar-width: none;
+}
+
+.service-rail::-webkit-scrollbar {
+ display: none;
+}
+
+.service-tile {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ justify-items: center;
+ align-items: center;
+ gap: 0.45rem;
+ width: 7.8rem;
+ min-width: 7.8rem;
+ height: 7.8rem;
+ padding: 0.7rem;
+ border: 0;
+ border-radius: 1rem;
+ background: rgb(var(--nodedc-card-passive-rgb));
+ color: var(--text-primary);
+ text-align: center;
+}
+
+.service-tile:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.service-tile--active {
+ background: rgb(var(--nodedc-card-active-rgb));
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.service-tile--active small,
+.service-tile--active .status-badge {
+ color: rgba(11, 17, 23, 0.72);
+}
+
+.service-tile__media {
+ display: grid;
+ width: 2.65rem;
+ height: 2.65rem;
+ place-items: center;
+ border-radius: 0.95rem;
+ background: color-mix(in srgb, var(--tile-accent) 36%, rgba(255, 255, 255, 0.12));
+}
+
+.service-tile__content {
+ min-width: 0;
+}
+
+.service-tile__content strong,
+.service-tile__content small {
+ display: -webkit-box;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -webkit-box-orient: vertical;
+}
+
+.service-tile__content strong {
+ min-height: 2.15rem;
+ font-size: 0.84rem;
+ line-height: 1.15;
+ -webkit-line-clamp: 2;
+}
+
+.service-tile__content small {
+ display: none;
+ color: var(--text-muted);
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45rem;
+ min-height: 2.65rem;
+ padding: 0 1rem;
+ border: 0;
+ border-radius: var(--launcher-radius-control);
+ font-weight: 800;
+ white-space: nowrap;
+ box-shadow: none;
+}
+
+.button--primary {
+ background: rgb(var(--nodedc-accent-rgb));
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.button--primary:hover:not(:disabled) {
+ filter: brightness(1.08);
+}
+
+.button--secondary,
+.button--ghost {
+ background: rgba(255, 255, 255, 0.09);
+ color: var(--text-primary);
+}
+
+.button--secondary:hover:not(:disabled),
+.button--ghost:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.button--danger {
+ background: rgba(255, 116, 116, 0.16);
+ color: #ffd5d5;
+}
+
+.button:disabled {
+ opacity: 0.55;
+}
+
+.icon-button {
+ display: grid;
+ width: 2.45rem;
+ height: 2.45rem;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.09);
+ color: var(--text-primary);
+}
+
+.icon-button:hover {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ width: max-content;
+ min-height: 1.6rem;
+ padding: 0 0.58rem;
+ border-radius: var(--launcher-radius-circle);
+ background: rgba(255, 255, 255, 0.09);
+ color: var(--text-secondary);
+ font-size: 0.72rem;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.status-badge--green {
+ background: rgba(181, 255, 90, 0.16);
+ color: #d9ffac;
+}
+
+.status-badge--yellow {
+ background: rgba(255, 209, 102, 0.16);
+ color: #ffe2a1;
+}
+
+.status-badge--red {
+ background: rgba(255, 116, 116, 0.16);
+ color: #ffd0d0;
+}
+
+.status-badge--violet {
+ background: rgba(215, 200, 255, 0.16);
+ color: #e7dcff;
+}
+
+.muted-text {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+.admin-panel-layer {
+ position: absolute;
+ z-index: 8;
+ top: 0;
+ bottom: calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap));
+ left: var(--launcher-page-pad);
+ display: flex;
+ gap: var(--admin-panel-gap);
+ pointer-events: none;
+}
+
+.admin-panel-nav,
+.admin-panel-content {
+ pointer-events: auto;
+ border: 0;
+ border-radius: var(--launcher-radius-card);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
+ rgba(10, 10, 13, 0.88);
+ box-shadow: 0 34px 110px rgba(0, 0, 0, 0.52);
+ backdrop-filter: blur(28px);
+ -webkit-backdrop-filter: blur(28px);
+}
+
+.admin-panel-nav {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto auto 1fr auto;
+ gap: 1.05rem;
+ width: var(--admin-nav-width);
+ min-width: var(--admin-nav-width);
+ padding: var(--admin-nav-pad);
+ animation: adminPanelSlide 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+.admin-panel-content {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 1rem;
+ width: var(--admin-content-width);
+ min-width: 0;
+ overflow: hidden;
+ padding: 1rem;
+ font-size: 0.875rem;
+ animation: adminPanelSlide 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+.admin-panel-nav__head {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 3.05rem;
+}
+
+.admin-panel-nav__head h2 {
+ margin: 0.15rem 0 0;
+ font-size: 1.15rem;
+ line-height: 1.1;
+}
+
+.admin-panel-close {
+ position: absolute;
+ top: var(--admin-control-inset);
+ right: var(--admin-control-inset);
+ width: var(--admin-control-ring);
+ height: var(--admin-control-ring);
+ flex: 0 0 auto;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ background: transparent !important;
+ background-image: none !important;
+ box-shadow: none;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.admin-panel-close:hover {
+ border-color: rgba(255, 255, 255, 0.28);
+ background: rgba(255, 255, 255, 0.07) !important;
+ color: var(--text-primary);
+}
+
+.admin-panel-client-select {
+ position: relative;
+ display: flex;
+ width: calc(100% + (var(--admin-nav-pad) * 2));
+ min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2));
+ margin-inline: calc(var(--admin-nav-pad) * -1);
+ align-items: center;
+ gap: 0.65rem;
+ overflow: hidden;
+ border-radius: var(--launcher-radius-circle);
+ background: rgba(64, 64, 64, 0.48);
+ padding: var(--admin-control-inset) calc(var(--admin-control-inset) + 1.9rem) var(--admin-control-inset)
+ var(--admin-control-inset);
+ color: var(--text-primary);
+}
+
+.admin-panel-client-select__icon,
+.admin-panel-nav-item__icon {
+ display: grid;
+ width: var(--admin-control-ring);
+ height: var(--admin-control-ring);
+ place-items: center;
+ flex: 0 0 auto;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: var(--launcher-radius-circle);
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.admin-panel-client-select__icon {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.admin-panel-client-select__name {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 0.92rem;
+ font-weight: 800;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-panel-client-select__chevron {
+ position: absolute;
+ top: 50%;
+ right: var(--admin-control-inset);
+ display: grid;
+ width: 1.85rem;
+ height: 1.85rem;
+ place-items: center;
+ color: var(--text-muted);
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.admin-panel-client-select select {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.admin-panel-client-select option {
+ color: #101318;
+}
+
+.admin-panel-nav-list {
+ display: grid;
+ width: calc(100% + (var(--admin-nav-pad) * 2));
+ margin-inline: calc(var(--admin-nav-pad) * -1);
+ align-content: start;
+ gap: 0.22rem;
+ overflow-x: visible;
+ overflow-y: auto;
+}
+
+.admin-panel-nav-item,
+.admin-panel-role {
+ position: relative;
+ display: flex;
+ width: 100%;
+ min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2));
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.86rem;
+ border: 0;
+ border-radius: var(--launcher-radius-circle);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text-primary);
+ padding: var(--admin-control-inset) 0.9rem var(--admin-control-inset)
+ calc(var(--admin-control-inset) + var(--admin-control-ring) + 0.86rem);
+ font-size: 0.91rem;
+ font-weight: 570;
+ text-align: left;
+ opacity: 0.72;
+ transition:
+ background 160ms ease,
+ color 160ms ease,
+ opacity 160ms ease,
+ transform 160ms ease,
+ box-shadow 160ms ease;
+}
+
+.admin-panel-nav-item .admin-panel-nav-item__icon,
+.admin-panel-role .admin-panel-nav-item__icon {
+ position: absolute;
+ top: 50%;
+ left: var(--admin-control-inset);
+ transform: translateY(-50%);
+ border: 0;
+ background: rgba(255, 255, 255, 0.07);
+ color: rgba(255, 255, 255, 0.74);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.05),
+ 0 8px 20px rgba(0, 0, 0, 0.16);
+}
+
+.admin-panel-nav-item:hover {
+ background: rgba(255, 255, 255, 0.085);
+ color: var(--text-primary);
+ opacity: 0.92;
+ transform: translateY(-1px);
+}
+
+.admin-panel-nav-item > span:last-child,
+.admin-panel-role > span:last-child {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-panel-nav-item--active {
+ background: rgba(255, 255, 255, 0.115);
+ color: var(--text-primary);
+ opacity: 0.82;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.048),
+ 0 10px 22px rgba(0, 0, 0, 0.12);
+}
+
+.admin-panel-nav-item--active .admin-panel-nav-item__icon {
+ background: rgb(var(--nodedc-card-active-rgb));
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.admin-panel-role {
+ width: calc(100% + (var(--admin-nav-pad) * 2));
+ margin-inline: calc(var(--admin-nav-pad) * -1);
+ background: rgba(255, 255, 255, 0.04);
+ opacity: 0.82;
+}
+
+.admin-panel-content__body {
+ min-height: 0;
+ overflow: auto;
+}
+
+.admin-panel-content .admin-section-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.admin-panel-content .access-layout,
+.admin-panel-content .invites-layout {
+ grid-template-columns: 1fr;
+ overflow: visible;
+}
+
+.admin-panel-content .glass-surface {
+ background: rgba(255, 255, 255, 0.055);
+ box-shadow: none;
+}
+
+.admin-panel-content .table-shell {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.admin-panel-content .table-shell,
+.admin-panel-content .access-matrix,
+.admin-panel-content .access-explanation,
+.admin-panel-content .invite-form,
+.admin-panel-content .company-panel {
+ max-width: 100%;
+}
+
+@keyframes adminPanelSlide {
+ from {
+ opacity: 0;
+ transform: translateX(-1.6rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.admin-backdrop {
+ position: fixed;
+ z-index: 100;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: clamp(0.8rem, 3vw, 2rem);
+ background: rgba(0, 0, 0, 0.22);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
+}
+
+.admin-overlay {
+ position: relative;
+ display: grid;
+ grid-template-columns: 18.5rem minmax(0, 1fr);
+ width: min(74rem, calc(100vw - 3rem));
+ height: min(48rem, calc(100vh - 4rem));
+ overflow: hidden;
+ border-radius: var(--launcher-radius-modal);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%),
+ rgba(8, 8, 11, 0.9);
+ box-shadow:
+ 0 28px 76px rgba(0, 0, 0, 0.5),
+ 0 8px 24px rgba(0, 0, 0, 0.26),
+ inset 0 1px 0 rgba(255, 255, 255, 0.036);
+}
+
+.admin-window-close {
+ position: absolute;
+ top: 0.7rem;
+ right: 0.7rem;
+ z-index: 10;
+ width: 2.75rem;
+ height: 2.75rem;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.admin-sidebar {
+ display: grid;
+ grid-template-rows: auto auto 1fr auto;
+ gap: 1rem;
+ min-width: 0;
+ padding: 1rem;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%),
+ rgba(8, 8, 11, 0.58);
+}
+
+.admin-sidebar__head {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 0.8rem;
+}
+
+.admin-sidebar h2,
+.admin-header h1,
+.table-toolbar h3,
+.admin-wide-card h3,
+.access-explanation h3,
+.invite-form h3,
+.company-panel h3 {
+ margin: 0;
+}
+
+.admin-nav {
+ display: grid;
+ align-content: start;
+ gap: 0.4rem;
+ overflow-y: auto;
+}
+
+.admin-nav__item {
+ display: flex;
+ align-items: center;
+ gap: 0.62rem;
+ min-height: 2.75rem;
+ padding: 0 0.75rem;
+ border: 0;
+ border-radius: 1.1rem;
+ background: transparent;
+ color: var(--text-secondary);
+ text-align: left;
+}
+
+.admin-nav__item:hover,
+.admin-nav__item--active {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-primary);
+}
+
+.admin-nav__item--active {
+ color: rgb(var(--nodedc-accent-rgb));
+ box-shadow: none;
+}
+
+.admin-sidebar__foot,
+.admin-client-lock {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ min-height: 2.65rem;
+ padding: 0 0.75rem;
+ border-radius: 1.35rem;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-secondary);
+}
+
+.admin-content {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ gap: 1rem;
+ min-width: 0;
+ overflow: hidden;
+ padding: 1rem;
+}
+
+.admin-header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 1rem;
+ min-height: var(--admin-control-ring);
+}
+
+.admin-header__actions,
+.table-toolbar,
+.access-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.65rem;
+}
+
+.admin-circle-action {
+ width: var(--admin-control-ring);
+ height: var(--admin-control-ring);
+ min-width: var(--admin-control-ring);
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ background: transparent !important;
+ background-image: none !important;
+ color: rgba(255, 255, 255, 0.82);
+ box-shadow: none;
+}
+
+.admin-circle-action:hover {
+ border-color: rgba(255, 255, 255, 0.3);
+ background: rgba(255, 255, 255, 0.07) !important;
+ color: var(--text-primary);
+}
+
+.admin-circle-action--solid {
+ border-color: rgba(247, 248, 244, 0.96);
+ background: rgba(247, 248, 244, 0.96) !important;
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.admin-circle-action--solid:hover {
+ border-color: #fff;
+ background: #fff !important;
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.admin-section-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1rem;
+ overflow-y: auto;
+}
+
+.metric-card {
+ display: grid;
+ gap: 0.3rem;
+ min-height: 8rem;
+ padding: 1rem;
+}
+
+.metric-card span,
+.metric-card small {
+ color: var(--text-muted);
+}
+
+.metric-card strong {
+ font-size: 2.2rem;
+}
+
+.metric-card--danger strong {
+ color: #ffd0d0;
+}
+
+.admin-wide-card {
+ grid-column: 1 / -1;
+ padding: 1rem;
+}
+
+.activity-list {
+ display: grid;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.activity-row {
+ display: grid;
+ grid-template-columns: 9.5rem 1fr 1fr;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ border-radius: 0.9rem;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.activity-row span,
+.activity-row em {
+ color: var(--text-muted);
+ font-style: normal;
+}
+
+.table-shell,
+.access-matrix,
+.access-explanation,
+.invite-form,
+.company-panel {
+ min-width: 0;
+ overflow: auto;
+ padding: 1rem;
+}
+
+.table-toolbar {
+ margin-bottom: 0.7rem;
+}
+
+.admin-panel-content table {
+ font-size: 0.82rem;
+}
+
+.admin-panel-content th {
+ padding: 0.62rem 0.75rem;
+ font-size: 0.66rem;
+}
+
+.admin-panel-content td {
+ padding: 0.48rem 0.75rem;
+ vertical-align: middle;
+}
+
+.admin-panel-content td strong {
+ font-size: 0.84rem;
+}
+
+.admin-panel-content td small {
+ font-size: 0.72rem;
+}
+
+.services-table-shell {
+ border-radius: var(--launcher-radius-card);
+}
+
+.services-admin-table {
+ table-layout: fixed;
+}
+
+.services-admin-table th:nth-child(1) {
+ width: 24%;
+}
+
+.services-admin-table th:nth-child(2) {
+ width: 15%;
+}
+
+.services-admin-table th:nth-child(3) {
+ width: 11%;
+}
+
+.services-admin-table th:nth-child(4) {
+ width: 24%;
+}
+
+.services-admin-table th:nth-child(5) {
+ width: 15%;
+}
+
+.services-admin-table th:nth-child(6) {
+ width: 7%;
+}
+
+.services-admin-table th:nth-child(7) {
+ width: 3.9rem;
+}
+
+.services-admin-table__service {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.admin-table-input {
+ width: 100%;
+ min-width: 0;
+ min-height: 2rem;
+ border: 0;
+ border-radius: 0.7rem;
+ background: transparent;
+ color: var(--text-primary);
+ padding: 0.18rem 0.35rem;
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+
+.admin-table-input:hover,
+.admin-table-input:focus {
+ background: rgba(255, 255, 255, 0.055);
+}
+
+.admin-table-input--strong {
+ font-size: 0.86rem;
+ font-weight: 780;
+}
+
+.admin-table-input--muted {
+ min-height: 1.35rem;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+}
+
+.admin-table-input--order {
+ text-align: center;
+}
+
+.admin-table-select {
+ appearance: none;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.06);
+ padding-inline: 0.55rem;
+}
+
+.services-admin-table__actions {
+ text-align: right;
+}
+
+.services-admin-table__edit {
+ width: 2.35rem;
+ min-width: 2.35rem;
+ height: 2.35rem;
+ margin-left: auto;
+}
+
+.service-content-modal-layer {
+ position: fixed;
+ z-index: 60;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 1.4rem;
+ background: rgba(0, 0, 0, 0.38);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+.service-content-modal {
+ position: relative;
+ display: grid;
+ width: min(58rem, calc(100vw - 2.8rem));
+ max-height: min(44rem, calc(100vh - 2.8rem));
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 1rem;
+ overflow: hidden;
+ padding: 1rem;
+ border-radius: var(--launcher-radius-modal);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)),
+ rgba(10, 10, 13, 0.9);
+ box-shadow: 0 34px 120px rgba(0, 0, 0, 0.62);
+ backdrop-filter: blur(34px) saturate(1.12);
+ -webkit-backdrop-filter: blur(34px) saturate(1.12);
+}
+
+.service-content-modal__head,
+.service-content-modal__foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.service-content-modal__foot-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.service-content-modal__head h3 {
+ margin: 0.1rem 0 0;
+ font-size: 1.05rem;
+}
+
+.service-content-modal__grid {
+ display: grid;
+ min-height: 0;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+ overflow: auto;
+ padding-right: 0.2rem;
+}
+
+.service-content-field {
+ display: grid;
+ gap: 0.38rem;
+}
+
+.service-content-field--wide {
+ grid-column: 1 / -1;
+}
+
+.service-content-field span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.service-content-field input,
+.service-content-field textarea {
+ width: 100%;
+ border: 0;
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text-primary);
+ padding: 0.75rem 0.85rem;
+ font-size: 0.84rem;
+}
+
+.service-media-field {
+ min-width: 0;
+}
+
+.service-media-control {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ min-height: 3.35rem;
+ overflow: hidden;
+ border-radius: var(--launcher-radius-circle);
+ background: rgba(255, 255, 255, 0.06);
+ padding: 0.25rem;
+}
+
+.service-media-control .service-media-url-input {
+ min-width: 0;
+ height: 100%;
+ border-radius: var(--launcher-radius-circle);
+ background: transparent;
+ padding: 0 0.82rem;
+}
+
+.service-media-file-control {
+ display: flex;
+ min-width: 0;
+ height: 100%;
+ align-items: center;
+ gap: 0.7rem;
+ padding-left: 0.15rem;
+}
+
+.service-media-file-control input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
+
+.service-media-file-button {
+ display: inline-flex;
+ min-height: 2.78rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--launcher-radius-circle);
+ background: rgba(255, 255, 255, 0.92);
+ color: rgba(8, 8, 10, 0.96);
+ padding: 0 1rem;
+ font-size: 0.78rem;
+ font-weight: 850;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.service-media-file-name {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ font-weight: 650;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.service-media-source-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.12rem;
+}
+
+.service-media-source-button {
+ display: grid;
+ width: 2.78rem;
+ height: 2.78rem;
+ place-items: center;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: var(--launcher-radius-circle);
+ background: transparent;
+ color: rgba(255, 255, 255, 0.66);
+}
+
+.service-media-source-button:hover {
+ background: rgba(255, 255, 255, 0.07);
+ color: var(--text-primary);
+}
+
+.service-media-source-button[data-active="true"] {
+ border-color: rgba(255, 255, 255, 0.92);
+ background: rgba(255, 255, 255, 0.92);
+ color: rgba(8, 8, 10, 0.96);
+}
+
+.service-content-field textarea {
+ resize: vertical;
+ line-height: 1.45;
+}
+
+.service-content-field--upload input {
+ min-height: 2.95rem;
+ padding: 0.62rem;
+}
+
+.service-content-preview {
+ display: grid;
+ min-height: 13rem;
+ place-items: center;
+ overflow: hidden;
+ border-radius: 1.1rem;
+ background: rgba(255, 255, 255, 0.045);
+ color: var(--text-muted);
+}
+
+.service-content-preview img,
+.service-content-preview video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.service-content-storage-error {
+ grid-column: 1 / -1;
+ margin: 0;
+ border-radius: var(--launcher-radius-control);
+ background: rgba(255, 116, 116, 0.13);
+ color: #ffd0d0;
+ padding: 0.7rem 0.85rem;
+ font-size: 0.78rem;
+ font-weight: 750;
+}
+
+.service-content-modal .button--primary {
+ background: rgba(247, 248, 244, 0.96);
+ color: rgb(var(--nodedc-on-accent-rgb));
+}
+
+.service-content-modal .button--secondary {
+ background: transparent;
+ color: var(--text-primary);
+}
+
+.access-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 21rem;
+ gap: 1rem;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.matrix-scroll {
+ overflow: auto;
+}
+
+.access-cell {
+ display: grid;
+ gap: 0.2rem;
+ width: 100%;
+ min-width: 8rem;
+ min-height: 3.25rem;
+ padding: 0.55rem;
+ border: 0;
+ border-radius: 0.85rem;
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text-secondary);
+ text-align: left;
+}
+
+.access-cell--allowed {
+ background: rgba(181, 255, 90, 0.14);
+ color: #defeb2;
+}
+
+.access-cell--denied {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.access-cell--exception {
+ background: rgba(255, 116, 116, 0.16);
+ color: #ffd0d0;
+}
+
+.access-cell--active {
+ background: rgba(255, 255, 255, 0.12);
+ box-shadow: none;
+}
+
+.access-cell span {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+
+.access-explanation {
+ display: grid;
+ align-content: start;
+ gap: 1rem;
+}
+
+.explanation-stack {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.info-line {
+ display: grid;
+ gap: 0.25rem;
+ padding: 0.72rem;
+ border-radius: 0.9rem;
+ background: rgba(255, 255, 255, 0.07);
+}
+
+.info-line span {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+}
+
+.info-line strong {
+ color: var(--text-secondary);
+ line-height: 1.35;
+}
+
+.invites-layout {
+ display: grid;
+ grid-template-columns: 21rem minmax(0, 1fr);
+ gap: 1rem;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.invite-form {
+ display: grid;
+ align-content: start;
+ gap: 0.75rem;
+}
+
+.invite-form input,
+.invite-form select {
+ min-height: 2.7rem;
+ border: 0;
+ border-radius: var(--launcher-radius-control);
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-primary);
+ padding: 0 0.8rem;
+}
+
+.company-panel {
+ max-width: 42rem;
+}
+
+.empty-stage-card {
+ display: grid;
+ gap: 0.7rem;
+ width: min(25rem, 92vw);
+ padding: 1.4rem;
+}
+
+.empty-stage-card h1,
+.empty-stage-card p {
+ margin: 0;
+}
+
+.empty-stage-card p {
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+.portal-dropdown {
+ position: fixed;
+ z-index: 420;
+ padding: 0.65rem;
+}
+
+@media (max-width: 1120px) {
+ .nodedc-expanded-toolbar {
+ min-height: 8.25rem;
+ }
+
+ .nodedc-expanded-toolbar-top {
+ grid-template-columns: 1fr auto;
+ }
+
+ .nodedc-expanded-toolbar-center {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ justify-content: flex-start;
+ justify-self: start;
+ overflow-x: auto;
+ width: 100%;
+ }
+
+ .admin-overlay,
+ .access-layout,
+ .invites-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-overlay {
+ grid-template-rows: auto 1fr;
+ height: min(52rem, calc(100vh - 1.6rem));
+ }
+
+ .admin-sidebar {
+ grid-template-rows: auto auto auto;
+ border-right: 0;
+ }
+
+ .admin-nav {
+ grid-auto-flow: column;
+ grid-auto-columns: max-content;
+ overflow-x: auto;
+ }
+
+ .stage-service-overlay {
+ width: min(62rem, calc(100% - 5rem));
+ gap: 0.8rem;
+ }
+}
+
+@media (max-width: 760px) {
+ .launcher-app {
+ padding: 0;
+ }
+
+ .nodedc-expanded-toolbar-shell {
+ padding: 1rem 1rem 0.75rem;
+ }
+
+ .nodedc-expanded-toolbar {
+ min-height: 10.5rem;
+ }
+
+ .nodedc-expanded-toolbar-top {
+ grid-template-columns: 1fr;
+ }
+
+ .nodedc-expanded-toolbar-center,
+ .nodedc-expanded-toolbar-right {
+ justify-content: flex-start;
+ overflow-x: auto;
+ width: 100%;
+ }
+
+ .nodedc-expanded-nav-group {
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
+ .nodedc-expanded-user-group {
+ max-width: 100%;
+ }
+
+ .select-shell,
+ .button {
+ width: auto;
+ }
+
+ .launcher-main {
+ min-height: calc(100vh - 12rem);
+ }
+
+ .service-stage {
+ padding: 0 0.7rem 10rem;
+ }
+
+ .stage-video-shell {
+ width: 100%;
+ height: min(32rem, calc(100vh - 20rem));
+ min-height: 28rem;
+ border-radius: 1.45rem;
+ }
+
+ .stage-service-overlay {
+ top: 50%;
+ left: 50%;
+ width: calc(100% - 2rem);
+ grid-template-columns: 1fr;
+ max-height: calc(100% - 5rem);
+ overflow-y: auto;
+ transform: translate(-50%, -50%);
+ }
+
+ .stage-side-controls,
+ .stage-timeline-strip {
+ display: none;
+ }
+
+ .stage-image-card,
+ .stage-description-card {
+ border-radius: 1.2rem;
+ }
+
+ .stage-image-card__visual {
+ inset: 0;
+ }
+
+ .service-rail {
+ right: 0.65rem;
+ bottom: 0.65rem;
+ left: 0.65rem;
+ padding: 0.55rem;
+ }
+
+ .service-tile {
+ width: 6.9rem;
+ min-width: 6.9rem;
+ height: 6.9rem;
+ }
+
+ .service-tile__media {
+ width: 2.35rem;
+ height: 2.35rem;
+ }
+
+ .admin-backdrop {
+ padding: 0.55rem;
+ align-items: stretch;
+ }
+
+ .admin-overlay {
+ width: 100%;
+ height: 100%;
+ }
+
+ .admin-content {
+ padding: 0.65rem;
+ }
+
+ .admin-header,
+ .admin-header__actions,
+ .table-toolbar,
+ .access-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .admin-section-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .activity-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx
new file mode 100644
index 0000000..0a94600
--- /dev/null
+++ b/src/widgets/admin-overlay/AdminOverlay.tsx
@@ -0,0 +1,1193 @@
+import { useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ Building2,
+ ChevronDown,
+ ClipboardList,
+ DatabaseZap,
+ Edit3,
+ Globe2,
+ HardDrive,
+ Image as ImageIcon,
+ KeyRound,
+ LayoutDashboard,
+ Link2,
+ ListChecks,
+ MailPlus,
+ Plus,
+ RefreshCw,
+ Save,
+ SearchCheck,
+ ShieldCheck,
+ Trash2,
+ UsersRound,
+ Video,
+ X,
+} from "lucide-react";
+import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types";
+import type { Invite } from "../../entities/invite/types";
+import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
+import type { SyncStatus } from "../../entities/sync/types";
+import type { ClientMembershipRole } from "../../entities/user/types";
+import {
+ buildAccessMatrix,
+ getClient,
+ getClientUsers,
+ getService,
+ getUser,
+ type AccessMatrixCell,
+ type LauncherData,
+ type MeResponse,
+} from "../../shared/api/mockApi";
+import { uploadStorageFile } from "../../shared/api/storageApi";
+import { cn } from "../../shared/lib/cn";
+import { formatDate, formatDateTime } from "../../shared/lib/format";
+import { Button, IconButton } from "../../shared/ui/Button";
+import { GlassSurface } from "../../shared/ui/Glass";
+import { ClientStatusBadge, ServiceStatusBadge, SyncStatusBadge, UserStatusBadge } from "../../shared/ui/StatusBadge";
+
+type AdminSection =
+ | "overview"
+ | "clients"
+ | "users"
+ | "groups"
+ | "services"
+ | "access"
+ | "invites"
+ | "sync"
+ | "audit"
+ | "company";
+
+const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
+ { id: "overview", label: "Обзор", icon: },
+ { id: "clients", label: "Клиенты", icon: },
+ { id: "users", label: "Участники", icon: },
+ { id: "groups", label: "Группы", icon: },
+ { id: "services", label: "Каталог сервисов", icon: },
+ { id: "access", label: "Доступы", icon: },
+ { id: "invites", label: "Инвайты", icon: },
+ { id: "sync", label: "Синхронизация", icon: },
+ { id: "audit", label: "Аудит", icon: },
+];
+
+const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
+ { id: "overview", label: "Обзор", icon: },
+ { id: "users", label: "Участники", icon: },
+ { id: "groups", label: "Группы", icon: },
+ { id: "access", label: "Доступы", icon: },
+ { id: "invites", label: "Инвайты", icon: },
+ { id: "company", label: "Профиль компании", icon: },
+ { id: "sync", label: "Синхронизация", icon: },
+];
+
+export function AdminOverlay({
+ data,
+ me,
+ activeClientId,
+ onClose,
+ onCreateGrant,
+ onCreateDenyException,
+ onRemoveException,
+ onCreateInvite,
+ onRetrySync,
+ onUpdateService,
+ onCreateService,
+ onDeleteService,
+}: {
+ data: LauncherData;
+ me: MeResponse;
+ activeClientId: string;
+ onClose: () => void;
+ onCreateGrant: (grant: Omit) => void;
+ onCreateDenyException: (exception: Omit) => void;
+ onRemoveException: (exceptionId: string) => void;
+ onCreateInvite: (invite: Pick) => void;
+ onRetrySync: (syncId: string) => void;
+ onUpdateService: (serviceId: string, patch: Partial) => void;
+ onCreateService: () => void;
+ onDeleteService: (serviceId: string) => void;
+}) {
+ const isRoot = me.launcherRole === "root_admin";
+ const sections = isRoot ? rootSections : clientSections;
+ const [activeSection, setActiveSection] = useState(null);
+ const [selectedClientId, setSelectedClientId] = useState(activeClientId);
+ const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
+
+ const scopedClientId = isRoot ? selectedClientId : activeClientId;
+ const currentClient = getClient(data, scopedClientId);
+ const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
+ const selectedAccessCell =
+ accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
+ accessMatrix.cells[0];
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [onClose]);
+
+ return (
+
+
+
+
+
NODE.DC
+
Администрирование
+
+
+
+
+
+
+ {isRoot ? (
+
+
+
+
+ {currentClient.name}
+
+
+
+ setSelectedClientId(event.target.value)}>
+ {data.clients.map((client) => (
+
+ {client.name}
+
+ ))}
+
+
+ ) : (
+
+
+
+
+ {currentClient.name}
+
+ )}
+
+
+ {sections.map((section) => (
+ setActiveSection((current) => (current === section.id ? null : section.id))}
+ >
+ {section.icon}
+ {section.label}
+
+ ))}
+
+
+
+
+
+
+ {roleLabel(me.launcherRole)}
+
+
+
+ {activeSection ? (
+
+
+
+ {activeSection === "overview" ?
: null}
+ {activeSection === "clients" && isRoot ?
: null}
+ {activeSection === "users" ?
: null}
+ {activeSection === "groups" ?
: null}
+ {activeSection === "services" && isRoot ? (
+
+ ) : null}
+ {activeSection === "access" ? (
+
setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
+ onCreateGrant={onCreateGrant}
+ onCreateDenyException={onCreateDenyException}
+ onRemoveException={onRemoveException}
+ />
+ ) : null}
+ {activeSection === "invites" ? (
+
+ ) : null}
+ {activeSection === "sync" ? : null}
+ {activeSection === "audit" && isRoot ? : null}
+ {activeSection === "company" ? : null}
+
+
+ ) : null}
+
+ );
+}
+
+function AdminHeader() {
+ return (
+
+ );
+}
+
+function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clientId: string; isRoot: boolean }) {
+ const clientUsers = getClientUsers(data, clientId);
+ const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length;
+ const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isRoot || sync.objectId === clientId)).length;
+
+ return (
+
+
+ service.status === "active").length} hint="В каталоге" />
+
+ 0} />
+
+
+ Последние действия
+
+ {data.auditEvents.slice(0, 5).map((event) => (
+
+ {formatDateTime(event.at)}
+ {event.action}
+ {event.objectName}
+
+ ))}
+
+
+
+ );
+}
+
+function ClientsSection({ data }: { data: LauncherData }) {
+ return (
+
+
+
Клиенты
+ }>
+ Создать клиента
+
+
+
+
+
+ Название
+ Тип
+ Статус
+ Участников
+ Demo
+ Контакт
+
+
+
+ {data.clients.map((client) => (
+
+
+ {client.name}
+ {client.legalName ?? "Частное лицо"}
+
+ {client.type === "company" ? "Компания" : "Частное лицо"}
+
+
+
+ {data.memberships.filter((membership) => membership.clientId === client.id).length}
+ {formatDate(client.demoEndsAt)}
+ {client.contactEmail}
+
+ ))}
+
+
+
+ );
+}
+
+function UsersSection({ data, clientId, isRoot }: { data: LauncherData; clientId: string; isRoot: boolean }) {
+ const rows = isRoot
+ ? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }))
+ : data.memberships
+ .filter((membership) => membership.clientId === clientId)
+ .map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
+
+ return (
+
+
+
Участники
+ }>
+ Пригласить
+
+
+
+
+
+ Пользователь
+ {isRoot ? Клиент : null}
+ Роль
+ Группы
+ Статус
+
+
+
+ {rows.map(({ membership, user, client }) => (
+
+
+ {user.name}
+ {user.email}
+
+ {isRoot ? {client.name} : null}
+ {roleLabel(membership.role)}
+ {data.groups.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id)).map((group) => group.name).join(", ") || "—"}
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function GroupsSection({ data, clientId }: { data: LauncherData; clientId: string }) {
+ const groups = data.groups.filter((group) => group.clientId === clientId);
+
+ return (
+
+
+
Группы
+ }>
+ Создать группу
+
+
+
+
+
+ Название
+ Описание
+ Участников
+ Подключённые сервисы
+
+
+
+ {groups.map((group) => (
+
+
+ {group.name}
+
+ {group.description}
+ {group.memberIds.length}
+ {data.grants.filter((grant) => grant.targetType === "group" && grant.targetId === group.id).length}
+
+ ))}
+
+
+
+ );
+}
+
+const serviceStatusOptions: Array<{ value: ServiceStatus; label: string }> = [
+ { value: "active", label: "Активен" },
+ { value: "maintenance", label: "Техработы" },
+ { value: "hidden", label: "Скрыт" },
+ { value: "disabled", label: "Отключён" },
+];
+
+const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
+
+function ServicesSection({
+ data,
+ onUpdateService,
+ onCreateService,
+ onDeleteService,
+}: {
+ data: LauncherData;
+ onUpdateService: (serviceId: string, patch: Partial) => void;
+ onCreateService: () => void;
+ onDeleteService: (serviceId: string) => void;
+}) {
+ const [contentServiceId, setContentServiceId] = useState(null);
+ const contentService = data.services.find((service) => service.id === contentServiceId) ?? null;
+
+ return (
+ <>
+
+
+
Каталог сервисов
+
+
+
+
+
+
+
+ {contentService ? (
+ setContentServiceId(null)}
+ onSave={(patch) => {
+ onUpdateService(contentService.id, patch);
+ setContentServiceId(null);
+ }}
+ onDelete={() => {
+ onDeleteService(contentService.id);
+ setContentServiceId(null);
+ }}
+ />
+ ) : null}
+ >
+ );
+}
+
+function ServiceContentModal({
+ service,
+ onClose,
+ onSave,
+ onDelete,
+}: {
+ service: Service;
+ onClose: () => void;
+ onSave: (patch: Partial) => void;
+ onDelete: () => void;
+}) {
+ const [draft, setDraft] = useState(service);
+ const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
+ const [storageError, setStorageError] = useState(null);
+
+ useEffect(() => {
+ setDraft(service);
+ setStorageError(null);
+ setUploadingSlot(null);
+ }, [service]);
+
+ function update(key: K, value: Service[K]) {
+ setDraft((current) => ({ ...current, [key]: value }));
+ }
+
+ async function handleCoverUpload(file?: File) {
+ if (!file) return;
+ await uploadServiceMedia(file, "cover");
+ }
+
+ async function handleAmbientUpload(file?: File) {
+ if (!file) return;
+ await uploadServiceMedia(file, "ambient");
+ }
+
+ async function uploadServiceMedia(file: File, slot: "cover" | "ambient") {
+ setStorageError(null);
+ setUploadingSlot(slot);
+
+ try {
+ const storedFile = await uploadStorageFile(file);
+ const mediaKind = mediaKindFromFile(file);
+
+ if (slot === "cover") {
+ update("coverImageUrl", storedFile.url);
+ update("coverMediaKind", mediaKind);
+ update("coverMediaSource", "file");
+ update("coverMediaFileName", storedFile.fileName);
+ } else {
+ update("ambientVideoUrl", storedFile.url);
+ update("ambientMediaKind", mediaKind);
+ update("ambientMediaSource", "file");
+ update("ambientMediaFileName", storedFile.fileName);
+ }
+ } catch (error) {
+ setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage");
+ } finally {
+ setUploadingSlot(null);
+ }
+ }
+
+ return (
+
+
+
+
+
Витрина сервиса
+
{service.title}
+
+
+
+
+
+
+
+
+ Название в витрине
+ update("title", event.target.value)} />
+
+
+
+ Подзаголовок
+ update("subtitle", event.target.value || null)} />
+
+
+
+ Короткое описание
+
+
+
+ Description
+
+
+
+
+ Ссылка запуска
+
+ update("launchUrl", event.target.value || null)} />
+
+
+
}
+ source={draft.coverMediaSource ?? "url"}
+ value={draft.coverImageUrl ?? ""}
+ fileName={draft.coverMediaFileName ?? null}
+ isUploading={uploadingSlot === "cover"}
+ onSourceChange={(source) => update("coverMediaSource", source)}
+ onUrlChange={(value) => {
+ update("coverImageUrl", value || null);
+ update("coverMediaSource", "url");
+ update("coverMediaKind", mediaKindFromUrl(value));
+ update("coverMediaFileName", null);
+ }}
+ onFileChange={handleCoverUpload}
+ />
+
+
}
+ source={draft.ambientMediaSource ?? "url"}
+ value={draft.ambientVideoUrl ?? ""}
+ fileName={draft.ambientMediaFileName ?? null}
+ isUploading={uploadingSlot === "ambient"}
+ onSourceChange={(source) => update("ambientMediaSource", source)}
+ onUrlChange={(value) => {
+ update("ambientVideoUrl", value || null);
+ update("ambientMediaSource", "url");
+ update("ambientMediaKind", mediaKindFromUrl(value));
+ update("ambientMediaFileName", null);
+ }}
+ onFileChange={handleAmbientUpload}
+ />
+
+
+ {draft.coverImageUrl ? : }
+
+
+
+ {draft.ambientVideoUrl ? : }
+
+
+ {storageError ?
{storageError}
: null}
+
+
+
+
+ Отмена
+
+
+ } onClick={onDelete}>
+ Удалить
+
+ }
+ onClick={() =>
+ onSave({
+ title: draft.title,
+ subtitle: draft.subtitle,
+ description: draft.description,
+ fullDescription: draft.fullDescription,
+ launchUrl: draft.launchUrl,
+ coverImageUrl: draft.coverImageUrl,
+ coverMediaKind: draft.coverMediaKind,
+ coverMediaSource: draft.coverMediaSource,
+ coverMediaFileName: draft.coverMediaFileName,
+ ambientVideoUrl: draft.ambientVideoUrl,
+ ambientMediaKind: draft.ambientMediaKind,
+ ambientMediaSource: draft.ambientMediaSource,
+ ambientMediaFileName: draft.ambientMediaFileName,
+ })
+ }
+ >
+ {uploadingSlot ? "Сохраняем файл" : "Сохранить"}
+
+
+
+
+
+ );
+}
+
+function MediaSourceField({
+ label,
+ icon,
+ source,
+ value,
+ fileName,
+ isUploading = false,
+ onSourceChange,
+ onUrlChange,
+ onFileChange,
+}: {
+ label: string;
+ icon: ReactNode;
+ source: ServiceMediaSource;
+ value: string;
+ fileName?: string | null;
+ isUploading?: boolean;
+ onSourceChange: (source: ServiceMediaSource) => void;
+ onUrlChange: (value: string) => void;
+ onFileChange: (file?: File) => void | Promise;
+}) {
+ const inputId = `${label.replace(/\s+/g, "-").toLowerCase()}-${source}`;
+
+ return (
+
+
+ {icon} {label}
+
+
+ {source === "file" ? (
+
+
+ Выберите файл
+
+ {isUploading ? "Сохраняем в storage..." : fileName ?? "Файл не выбран"}
+ onFileChange(event.currentTarget.files?.[0])} />
+
+ ) : (
+
onUrlChange(event.target.value)} placeholder="https://..." />
+ )}
+
+
+ onSourceChange("file")}
+ >
+
+
+ onSourceChange("url")}
+ >
+
+
+
+
+
+ );
+}
+
+function MediaPreview({ src, kind }: { src: string; kind?: MediaKind | null }) {
+ if (kind === "video" || mediaKindFromUrl(src) === "video") {
+ return ;
+ }
+
+ return ;
+}
+
+function mediaKindFromFile(file: File): MediaKind {
+ if (file.type.startsWith("video/")) return "video";
+ if (file.type === "image/gif" || /\.gif$/i.test(file.name)) return "gif";
+ return "image";
+}
+
+function mediaKindFromUrl(value: string): MediaKind | null {
+ if (!value.trim()) return null;
+ if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video";
+ if (/\.gif(\?.*)?$/i.test(value)) return "gif";
+ return "image";
+}
+
+function AccessSection({
+ data,
+ matrix,
+ selectedCell,
+ onSelectCell,
+ onCreateGrant,
+ onCreateDenyException,
+ onRemoveException,
+}: {
+ data: LauncherData;
+ matrix: ReturnType;
+ selectedCell: AccessMatrixCell;
+ onSelectCell: (cell: AccessMatrixCell) => void;
+ onCreateGrant: (grant: Omit) => void;
+ onCreateDenyException: (exception: Omit) => void;
+ onRemoveException: (exceptionId: string) => void;
+}) {
+ const selectedUser = getUser(data, selectedCell.userId);
+ const selectedService = getService(data, selectedCell.serviceId);
+ const denyException = data.exceptions.find(
+ (exception) => exception.serviceId === selectedCell.serviceId && exception.userId === selectedCell.userId && exception.type === "deny"
+ );
+
+ return (
+
+
+
+
Матрица доступа · {matrix.client.name}
+ Клик по ячейке открывает объяснение
+
+
+
+
+
+ Участник
+ {matrix.services.map((service) => (
+ {service.title}
+ ))}
+
+
+
+ {matrix.users.map((user) => (
+
+
+ {user.name}
+ {user.email}
+
+ {matrix.services.map((service) => {
+ const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!;
+ const active = selectedCell.userId === user.id && selectedCell.serviceId === service.id;
+ return (
+
+ onSelectCell(cell)}
+ >
+ {accessCellTitle(cell)}
+ {sourceLabel(cell.effectiveAccess.source)}
+
+
+ );
+ })}
+
+ ))}
+
+
+
+
+
+
+ Explanation panel
+
+ {selectedUser.name} / {selectedService.title}
+
+
+
+
+
+
+
+
+
+
+ {!selectedCell.effectiveAccess.allowed ? (
+ }
+ onClick={() =>
+ onCreateGrant({
+ serviceId: selectedService.id,
+ targetType: "user",
+ targetId: selectedUser.id,
+ appRole: "member",
+ })
+ }
+ >
+ Выдать пользователю
+
+ ) : null}
+ {denyException ? (
+ onRemoveException(denyException.id)}>
+ Убрать deny
+
+ ) : (
+
+ onCreateDenyException({
+ serviceId: selectedService.id,
+ userId: selectedUser.id,
+ reason: "Создано из mock-матрицы доступа.",
+ })
+ }
+ >
+ Создать deny
+
+ )}
+
+
+
+ );
+}
+
+function InvitesSection({
+ data,
+ clientId,
+ actorUserId,
+ onCreateInvite,
+}: {
+ data: LauncherData;
+ clientId: string;
+ actorUserId: string;
+ onCreateInvite: (invite: Pick) => void;
+}) {
+ const [email, setEmail] = useState("");
+ const [role, setRole] = useState("member");
+ const invites = data.invites.filter((invite) => invite.clientId === clientId);
+
+ return (
+
+
+ Mock invite
+ Создать приглашение
+ setEmail(event.target.value)} placeholder="email@company.ru" />
+ setRole(event.target.value as ClientMembershipRole)}>
+ member
+ client_admin
+
+ }
+ disabled={!email.trim()}
+ onClick={() => {
+ onCreateInvite({ clientId, email, role });
+ setEmail("");
+ setRole("member");
+ void actorUserId;
+ }}
+ >
+ Сгенерировать ссылку
+
+
+
+
+
+
Инвайты
+
+
+
+
+ Email
+ Роль
+ Статус
+ Ссылка
+ Истекает
+
+
+
+ {invites.map((invite) => (
+
+ {invite.email}
+ {invite.role}
+ {invite.status}
+
+ {`/invite/${invite.token}`}
+
+ {formatDate(invite.expiresAt)}
+
+ ))}
+
+
+
+
+ );
+}
+
+function SyncSection({
+ data,
+ clientId,
+ isRoot,
+ onRetrySync,
+}: {
+ data: LauncherData;
+ clientId: string;
+ isRoot: boolean;
+ onRetrySync: (syncId: string) => void;
+}) {
+ const syncRows = data.syncStatuses.filter((sync) => isRoot || sync.objectId === clientId || sync.objectName.includes(getClient(data, clientId).name));
+
+ return (
+
+
+
Синхронизация
+
+
+
+
+ Объект
+ Тип
+ Цель
+ Статус
+ Ошибка
+ Действие
+
+
+
+ {syncRows.map((sync) => (
+
+ {sync.objectName}
+ {sync.objectType}
+ {targetLabel(sync.target)}
+
+
+
+ {sync.error ?? "—"}
+
+ } onClick={() => onRetrySync(sync.id)}>
+ Повторить
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function AuditSection({ data }: { data: LauncherData }) {
+ return (
+
+
+
Аудит
+
+
+
+
+ Дата
+ Пользователь
+ Действие
+ Объект
+ Результат
+ Детали
+
+
+
+ {data.auditEvents.map((event) => (
+
+ {formatDateTime(event.at)}
+ {event.actorName}
+ {event.action}
+ {event.objectName}
+ {event.result}
+ {event.details ?? "—"}
+
+ ))}
+
+
+
+ );
+}
+
+function CompanySection({ data, clientId }: { data: LauncherData; clientId: string }) {
+ const client = getClient(data, clientId);
+
+ return (
+
+ Профиль компании
+ {client.name}
+
+
+
+
+
+
+
+
+ );
+}
+
+function MetricCard({ label, value, hint, danger = false }: { label: string; value: number; hint: string; danger?: boolean }) {
+ return (
+
+ {label}
+ {value}
+ {hint}
+
+ );
+}
+
+function InfoLine({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function roleLabel(role: string): string {
+ const labels: Record = {
+ root_admin: "Root Admin",
+ support_admin: "Support Admin",
+ client_owner: "Client Owner",
+ client_admin: "Client Admin",
+ member: "Member",
+ };
+ return labels[role] ?? role;
+}
+
+function sectionTitle(section: AdminSection): string {
+ const labels: Record = {
+ overview: "Обзор",
+ clients: "Клиенты",
+ users: "Участники",
+ groups: "Группы",
+ services: "Каталог сервисов",
+ access: "Доступы",
+ invites: "Инвайты",
+ sync: "Синхронизация",
+ audit: "Аудит",
+ company: "Профиль компании",
+ };
+ return labels[section];
+}
+
+function accessCellTitle(cell: AccessMatrixCell): string {
+ if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? "Deny" : "—";
+ return cell.effectiveAccess.appRole ?? "allow";
+}
+
+function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
+ if (!source) return "—";
+ const labels = {
+ client: "Клиент",
+ group: "Группа",
+ user: "Пользователь",
+ exception: "Исключение",
+ };
+ return labels[source];
+}
+
+function targetLabel(target: SyncStatus["target"]): string {
+ const labels: Record = {
+ authentik: "Authentik",
+ task_manager: "Task Manager",
+ nodedc: "NodeDC",
+ service: "Сервис",
+ };
+ return labels[target];
+}
diff --git a/src/widgets/service-rail/ServiceRail.tsx b/src/widgets/service-rail/ServiceRail.tsx
new file mode 100644
index 0000000..6aea94e
--- /dev/null
+++ b/src/widgets/service-rail/ServiceRail.tsx
@@ -0,0 +1,46 @@
+import { Activity, Bot, Boxes, ChartNoAxesColumnIncreasing, KeyRound, Network, Sparkles } from "lucide-react";
+import type { LauncherServiceView } from "../../entities/service/types";
+import { cn } from "../../shared/lib/cn";
+import { ServiceStatusBadge } from "../../shared/ui/StatusBadge";
+
+export function ServiceRail({
+ services,
+ selectedServiceId,
+ onSelect,
+}: {
+ services: LauncherServiceView[];
+ selectedServiceId?: string;
+ onSelect: (serviceId: string) => void;
+}) {
+ return (
+
+ {services.map((service) => (
+ onSelect(service.id)}
+ type="button"
+ >
+
+
+
+
+ {service.title}
+ {service.subtitle}
+
+
+
+ ))}
+
+ );
+}
+
+function ServiceIcon({ slug }: { slug: string }) {
+ if (slug.includes("task")) return ;
+ if (slug.includes("1c")) return ;
+ if (slug.includes("tender")) return ;
+ if (slug.includes("twin")) return ;
+ if (slug.includes("digital-modules")) return ;
+ if (slug.includes("internal")) return ;
+ return ;
+}
diff --git a/src/widgets/service-stage/ServiceStage.tsx b/src/widgets/service-stage/ServiceStage.tsx
new file mode 100644
index 0000000..4208dc4
--- /dev/null
+++ b/src/widgets/service-stage/ServiceStage.tsx
@@ -0,0 +1,251 @@
+import type { ReactNode } from "react";
+import {
+ Activity,
+ Bot,
+ Boxes,
+ ChartNoAxesColumnIncreasing,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ KeyRound,
+ LockKeyhole,
+ Network,
+ Wrench,
+} from "lucide-react";
+import type { LauncherServiceView } from "../../entities/service/types";
+import { Button } from "../../shared/ui/Button";
+import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge";
+
+const DEFAULT_STAGE_MEDIA = "/storage/default.gif";
+
+export function ServiceStage({
+ service,
+ hasServices,
+ onLaunch,
+}: {
+ service?: LauncherServiceView;
+ hasServices: boolean;
+ onLaunch: (service: LauncherServiceView) => void;
+}) {
+ if (!hasServices) {
+ return (
+
+
+
+
Нет доступных сервисов
+
Для активного клиента не найдено доступных приложений. Проверьте гранты или статус клиента.
+
+
+ );
+ }
+
+ const style = {
+ "--service-accent": service?.accentColor ?? "#C3FF66",
+ } as React.CSSProperties;
+ const disabledReason = service
+ ? service.status === "maintenance"
+ ? "Сервис временно недоступен"
+ : service.userAccess === "denied"
+ ? "Доступ не выдан"
+ : service.effectiveAccess.openEnabled
+ ? null
+ : "Открытие заблокировано"
+ : null;
+
+ return (
+
+
+
+ {service?.media.ambientVideo ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {service?.title ?? "Витрина NODE.DC"}
+
+
+
+
+
+
+
+
+
+ {service ? (
+
+
+
+ {service.media.coverImage || service.media.thumbnail ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ Description
+
+
+
+
{service.title}
+
+
+
+
+
+
+
+
+
+ Почему видно
+ {service.effectiveAccess.reason}
+
+
+
+
+ ) : service.userAccess === "denied" ? (
+
+ ) : (
+
+ )
+ }
+ onClick={() => onLaunch(service)}
+ disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
+ >
+ {disabledReason ?? "Открыть"}
+
+ }
+ onClick={() => onLaunch(service)}
+ disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
+ >
+ Перейти
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ServiceIcon({ slug }: { slug: string }) {
+ if (slug.includes("task")) return ;
+ if (slug.includes("1c")) return ;
+ if (slug.includes("tender")) return ;
+ if (slug.includes("twin")) return ;
+ return ;
+}
+
+function StageMedia({ src, kind, className }: { src: string; kind?: LauncherServiceView["media"]["coverKind"]; className: string }) {
+ if (kind === "video" || isVideoSource(src)) {
+ return ;
+ }
+
+ return ;
+}
+
+function RichDescription({ text }: { text: string }) {
+ const blocks = text
+ .replace(/\r\n/g, "\n")
+ .split(/\n{2,}/)
+ .map((block) => block.trim())
+ .filter(Boolean);
+
+ if (!blocks.length) return null;
+
+ return (
+
+ {blocks.map((block, index) => {
+ const lines = block.split("\n").map((line) => line.trim()).filter(Boolean);
+ const isList = lines.length > 0 && lines.every((line) => /^[-*]\s+/.test(line));
+
+ if (isList) {
+ return (
+
+ {lines.map((line, lineIndex) => (
+ {renderInlineRichText(line.replace(/^[-*]\s+/, ""))}
+ ))}
+
+ );
+ }
+
+ return
{renderInlineRichText(lines.join("\n"))}
;
+ })}
+
+ );
+}
+
+function renderInlineRichText(text: string): ReactNode[] {
+ const parts: ReactNode[] = [];
+ const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_|\n)/g;
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = pattern.exec(text))) {
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+
+ const token = match[0];
+ const key = `${match.index}-${token}`;
+
+ if (token === "\n") {
+ parts.push( );
+ } else if (token.startsWith("**") || token.startsWith("__")) {
+ parts.push({token.slice(2, -2)} );
+ } else if (token.startsWith("`")) {
+ parts.push({token.slice(1, -1)});
+ } else {
+ parts.push({token.slice(1, -1)} );
+ }
+
+ lastIndex = pattern.lastIndex;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return parts;
+}
+
+function isVideoSource(src: string) {
+ return /\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src);
+}
diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx
new file mode 100644
index 0000000..094735f
--- /dev/null
+++ b/src/widgets/top-bar/TopBar.tsx
@@ -0,0 +1,99 @@
+import { Inbox } from "lucide-react";
+import type { Client } from "../../entities/client/types";
+import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
+import { initials } from "../../shared/lib/format";
+
+export function TopBar({
+ me,
+ clients,
+ profileOptions,
+ activeProfileId,
+ activeClientId,
+ adminOpen,
+ onProfileChange,
+ onClientChange,
+ onOpenAdmin,
+ onOpenShowcase,
+}: {
+ me: MeResponse;
+ clients: Client[];
+ profileOptions: ProfileOption[];
+ activeProfileId: string;
+ activeClientId: string;
+ adminOpen: boolean;
+ onProfileChange: (userId: string) => void;
+ onClientChange: (clientId: string) => void;
+ onOpenAdmin: () => void;
+ onOpenShowcase: () => void;
+}) {
+ const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
+ const availableClients = clients.filter((client) => availableClientIds.has(client.id));
+ const activeClient = availableClients.find((client) => client.id === activeClientId);
+ const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
+
+ return (
+
+
+
+
+
+
+
+
+ onClientChange(event.target.value)} aria-label="Выбрать клиента">
+ {availableClients.map((client) => (
+
+ {client.name}
+
+ ))}
+
+
+
+
+
+ Витрина
+
+
+
+ {activeProfile?.label ?? me.user.name}
+ onProfileChange(event.target.value)} aria-label="Выбрать профиль">
+ {profileOptions.map((profile) => (
+
+ {profile.label}
+
+ ))}
+
+
+
+ {me.permissions.canOpenAdmin ? (
+
+ Администрирование
+
+ ) : null}
+
+
+
+
+
+
+ Профиль
+
+
+
+
+
+
+
+ {initials(me.user.name)}
+
+
+
+
+
+
+ );
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..7a7a882
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..be51a69
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..672717b
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,187 @@
+import { defineConfig, type Plugin } from "vite";
+import react from "@vitejs/plugin-react";
+import { randomUUID } from "node:crypto";
+import { existsSync } from "node:fs";
+import { mkdir, writeFile } from "node:fs/promises";
+import type { ServerResponse } from "node:http";
+import { dirname, extname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const projectRoot = dirname(fileURLToPath(import.meta.url));
+const maxStorageJsonBodyBytes = 260 * 1024 * 1024;
+
+function localStorageApiPlugin(): Plugin {
+ return {
+ name: "nodedc-local-storage-api",
+ configureServer(server) {
+ server.middlewares.use(async (req, res, next) => {
+ const pathname = req.url?.split("?")[0];
+
+ if (req.method === "POST" && pathname === "/api/storage/upload") {
+ try {
+ const payload = await readJsonBody(req);
+ const result = await saveUploadedFile(payload);
+ sendJson(res, 200, result);
+ } catch (error) {
+ sendJson(res, 500, { error: error instanceof Error ? error.message : "Upload failed" });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && pathname === "/api/storage/data") {
+ try {
+ const payload = await readJsonBody(req);
+ await saveLauncherData(payload);
+ sendJson(res, 200, { ok: true, url: "/storage/launcher-data.json" });
+ } catch (error) {
+ sendJson(res, 500, { error: error instanceof Error ? error.message : "Data save failed" });
+ }
+ return;
+ }
+
+ next();
+ });
+ },
+ configurePreviewServer(server) {
+ server.middlewares.use(async (req, res, next) => {
+ const pathname = req.url?.split("?")[0];
+
+ if (req.method === "POST" && pathname === "/api/storage/upload") {
+ try {
+ const payload = await readJsonBody(req);
+ const result = await saveUploadedFile(payload);
+ sendJson(res, 200, result);
+ } catch (error) {
+ sendJson(res, 500, { error: error instanceof Error ? error.message : "Upload failed" });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && pathname === "/api/storage/data") {
+ try {
+ const payload = await readJsonBody(req);
+ await saveLauncherData(payload);
+ sendJson(res, 200, { ok: true, url: "/storage/launcher-data.json" });
+ } catch (error) {
+ sendJson(res, 500, { error: error instanceof Error ? error.message : "Data save failed" });
+ }
+ return;
+ }
+
+ next();
+ });
+ },
+ };
+}
+
+async function readJsonBody(req: NodeJS.ReadableStream) {
+ const chunks: Buffer[] = [];
+ let totalBytes = 0;
+
+ for await (const chunk of req) {
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ totalBytes += buffer.byteLength;
+
+ if (totalBytes > maxStorageJsonBodyBytes) {
+ throw new Error("Файл слишком большой для локального mock-storage");
+ }
+
+ chunks.push(buffer);
+ }
+
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+}
+
+async function saveUploadedFile(payload: unknown) {
+ if (!isUploadPayload(payload)) {
+ throw new Error("Некорректный payload загрузки");
+ }
+
+ const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
+
+ if (!match) {
+ throw new Error("Файл должен прийти data-url с base64");
+ }
+
+ const mimeType = payload.mimeType || match[1] || "application/octet-stream";
+ const storedName = buildStoredFileName(payload.fileName, mimeType);
+ const fileBuffer = Buffer.from(match[2], "base64");
+
+ await Promise.all(
+ getWritableStorageRoots().map(async (storageRoot) => {
+ const uploadDir = join(storageRoot, "uploads");
+ await mkdir(uploadDir, { recursive: true });
+ await writeFile(join(uploadDir, storedName), fileBuffer);
+ })
+ );
+
+ return {
+ ok: true,
+ url: `/storage/uploads/${storedName}`,
+ fileName: storedName,
+ originalFileName: payload.fileName,
+ mimeType,
+ };
+}
+
+async function saveLauncherData(payload: unknown) {
+ await Promise.all(
+ getWritableStorageRoots().map(async (storageRoot) => {
+ await mkdir(storageRoot, { recursive: true });
+ await writeFile(join(storageRoot, "launcher-data.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
+ })
+ );
+}
+
+function getWritableStorageRoots() {
+ const roots = [join(projectRoot, "public", "storage")];
+ const distRoot = join(projectRoot, "dist");
+
+ if (existsSync(distRoot)) {
+ roots.push(join(distRoot, "storage"));
+ }
+
+ return roots;
+}
+
+function buildStoredFileName(fileName: string, mimeType: string) {
+ const extension = extname(fileName) || extensionFromMimeType(mimeType);
+ const rawBase = fileName.slice(0, extension ? -extension.length : undefined);
+ const safeBase =
+ rawBase
+ .normalize("NFKD")
+ .replace(/[^\w.-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80) || "upload";
+
+ return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBase}${extension.toLowerCase()}`;
+}
+
+function extensionFromMimeType(mimeType: string) {
+ if (mimeType === "image/jpeg") return ".jpg";
+ if (mimeType === "image/png") return ".png";
+ if (mimeType === "image/gif") return ".gif";
+ if (mimeType === "image/webp") return ".webp";
+ if (mimeType === "video/mp4") return ".mp4";
+ if (mimeType === "video/webm") return ".webm";
+ if (mimeType === "video/quicktime") return ".mov";
+ return "";
+}
+
+function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
+ res.statusCode = statusCode;
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(payload));
+}
+
+function isUploadPayload(payload: unknown): payload is { fileName: string; mimeType: string; dataUrl: string } {
+ if (!payload || typeof payload !== "object") return false;
+
+ const candidate = payload as { fileName?: unknown; mimeType?: unknown; dataUrl?: unknown };
+
+ return typeof candidate.fileName === "string" && typeof candidate.mimeType === "string" && typeof candidate.dataUrl === "string";
+}
+
+export default defineConfig({
+ plugins: [react(), localStorageApiPlugin()],
+});