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 +
+
+ + Label +
+
+ +
+
+``` + +```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 ( + + ); +} + +interface IconButtonProps extends ButtonHTMLAttributes { + label: string; +} + +export function IconButton({ label, className, children, ...props }: IconButtonProps) { + return ( + + ); +} 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 ( +
+ + + {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 ( + +
+

Клиенты

+ +
+ + + + + + + + + + + + + {data.clients.map((client) => ( + + + + + + + + + ))} + +
НазваниеТипСтатусУчастниковDemoКонтакт
+ {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 }) => ( + + + {isRoot ? : null} + + + + + ))} + +
ПользовательКлиентРольГруппыСтатус
+ {user.name} + {user.email} + {client.name}{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 ( + <> + +
+

Каталог сервисов

+ + + +
+ + + + + + + + + + + + + {data.services.map((service) => ( + + + + + + + + + + ))} + +
СервисSlugСтатусURLAuthentikПорядок +
+ onUpdateService(service.id, { title: event.target.value })} + aria-label={`Название сервиса ${service.title}`} + /> + onUpdateService(service.id, { subtitle: event.target.value || null })} + aria-label={`Подзаголовок сервиса ${service.title}`} + /> + + onUpdateService(service.id, { slug: event.target.value })} + aria-label={`Slug сервиса ${service.title}`} + /> + + + + onUpdateService(service.id, { url: event.target.value })} + aria-label={`URL сервиса ${service.title}`} + /> + + onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })} + aria-label={`Authentik slug сервиса ${service.title}`} + /> + + onUpdateService(service.id, { order: Number(event.target.value) || 0 })} + aria-label={`Порядок сервиса ${service.title}`} + /> + + setContentServiceId(service.id)} + > + + +
+
+ + {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}

+
+ + + +
+ +
+ + + + +