Standardize modal buttons and delete confirmations
This commit is contained in:
parent
adad0bd344
commit
17e007f49d
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"id": "accent-contrast",
|
||||
"name": "NodeDcAccentContrast",
|
||||
"kind": "utility",
|
||||
"status": "draft-stable",
|
||||
"summary": "Shared contrast helper that derives readable text color from a component accent color.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/lib/accentContrast.ts",
|
||||
"exports": [
|
||||
"getReadableNodedcTextRgb",
|
||||
"createNodedcAccentStyleVars",
|
||||
"getNodedcRelativeLuminance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/utils/src/theme/nodedc-accent.ts",
|
||||
"exports": [
|
||||
"getReadableNodedcTextRgb",
|
||||
"applyNodedcAccent"
|
||||
]
|
||||
}
|
||||
],
|
||||
"visualContract": {
|
||||
"darkTextRgb": [11, 17, 23],
|
||||
"lightTextRgb": [245, 247, 251],
|
||||
"luminanceThreshold": 0.52,
|
||||
"cssVars": [
|
||||
"--nodedc-component-accent-rgb",
|
||||
"--nodedc-component-on-accent-rgb"
|
||||
]
|
||||
},
|
||||
"rules": [
|
||||
"Any filled accent button or active accent chip must use the computed on-accent text color.",
|
||||
"Do not hardcode black or white text on an accent fill unless the accent contrast helper owns that decision.",
|
||||
"Component-local accent variables are allowed when global --nodedc-accent-rgb is not the intended color for that component."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"id": "button",
|
||||
"name": "Button",
|
||||
"kind": "primitive",
|
||||
"status": "draft-stable",
|
||||
"summary": "Canonical text/action button used by launcher stages, admin editors, and modal footers.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/ui/Button.tsx",
|
||||
"exports": ["Button"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": [
|
||||
"button",
|
||||
"button--primary",
|
||||
"button--secondary",
|
||||
"button--danger",
|
||||
"button--ghost",
|
||||
"button--accent",
|
||||
"button[data-surface='modal']"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": [
|
||||
"nodedc-modal-primary-button",
|
||||
"nodedc-modal-secondary-button",
|
||||
"nodedc-modal-danger-button"
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"props": {
|
||||
"variant": ["primary", "secondary", "danger", "ghost", "accent"],
|
||||
"surface": ["default", "modal"],
|
||||
"accentRgb": "optional RGB tuple used by accent variant",
|
||||
"icon": "optional leading icon"
|
||||
}
|
||||
},
|
||||
"visualContract": {
|
||||
"height": "2.65rem default, tokens.size.modalButtonHeight on modal surface",
|
||||
"radius": "control",
|
||||
"fontWeight": "800",
|
||||
"outline": "never",
|
||||
"modalSecondaryDanger": "transparent until hover",
|
||||
"modalAccent": "filled component accent with computed on-accent text"
|
||||
},
|
||||
"rules": [
|
||||
"Use Button instead of hand-written button classes for text actions.",
|
||||
"Use IconButton or circle-action-button for icon-only actions.",
|
||||
"Inside modals, pass surface='modal' on every footer button.",
|
||||
"Modal Save and modal destructive confirm use variant='accent' with local accentRgb when the app accent differs.",
|
||||
"Inline modal delete buttons use variant='danger' with surface='modal'; they are transparent until neutral hover.",
|
||||
"Do not use red filled delete buttons outside NodeDcDeleteModal confirmation.",
|
||||
"Filled accent variants must use accent-contrast variables for readable text."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"id": "delete-modal",
|
||||
"name": "NodeDcDeleteModal",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Canonical dark glass confirmation modal for destructive object deletion.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/DeleteModal.tsx",
|
||||
"exports": ["NodeDcDeleteModal"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": [
|
||||
"nodedc-delete-modal-layer",
|
||||
"nodedc-delete-modal",
|
||||
"nodedc-delete-modal__icon",
|
||||
"nodedc-delete-modal__foot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/ui/src/modals/alert-modal.tsx",
|
||||
"exports": ["AlertModalCore"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": [
|
||||
"nodedc-glass-modal",
|
||||
"nodedc-modal-alert-icon",
|
||||
"nodedc-modal-danger-button",
|
||||
"nodedc-modal-secondary-button"
|
||||
]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"fixed blurred overlay layer",
|
||||
"dark glass shell",
|
||||
"round alert icon",
|
||||
"title and description copy",
|
||||
"footer with secondary cancel and destructive confirm"
|
||||
],
|
||||
"visualContract": {
|
||||
"width": "min(34rem, viewport - 2.8rem)",
|
||||
"radius": "modal",
|
||||
"background": "transparent black detail glass, not monocolor fill",
|
||||
"blur": "44px detail glass pseudo-layer blur",
|
||||
"iconSize": "2.75rem circle",
|
||||
"iconColor": "component accent, white in launcher",
|
||||
"buttonHeight": "tokens.size.modalButtonHeight through Button surface='modal'",
|
||||
"buttonRadius": "control radius",
|
||||
"cancelButton": "Button variant='secondary' surface='modal'",
|
||||
"destructiveButton": "Button variant='accent' surface='modal' with computed on-accent text"
|
||||
},
|
||||
"rules": [
|
||||
"Use this component for every delete/destructive removal action.",
|
||||
"Never delete immediately from a row, footer, dropdown, or editor without this modal.",
|
||||
"Do not add visible outlines or hard stroke borders to the modal, icon, or buttons.",
|
||||
"The shell uses nodedc-glass-detail-surface so the panel blurs the actual content under it.",
|
||||
"The destructive button uses the shared Button accent variant and component-local accent contrast variables, not a hardcoded red fill.",
|
||||
"Cancel is secondary and stays left of the destructive confirm action.",
|
||||
"The description must name the object and describe dependent data that will also be removed."
|
||||
],
|
||||
"launcherBindings": [
|
||||
"service showcase deletion",
|
||||
"client/company deletion",
|
||||
"client membership removal",
|
||||
"client group deletion",
|
||||
"invite deletion"
|
||||
]
|
||||
}
|
||||
|
|
@ -39,9 +39,11 @@
|
|||
},
|
||||
"rules": [
|
||||
"Delete is left of Save inside the right footer action group.",
|
||||
"Footer buttons use the shared Button primitive with surface='modal'.",
|
||||
"Inline delete buttons inside edit modals use Button variant='danger' surface='modal', stay transparent until hover, and do not use red danger fills.",
|
||||
"Save uses Button variant='accent' surface='modal' with local white launcher accent and computed on-accent text.",
|
||||
"Cancel stays on the left.",
|
||||
"Close action is circular and transparent until hover.",
|
||||
"Fields/selects/textareas share the same glass family."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,25 +12,24 @@
|
|||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["glass-surface", "admin-panel-nav", "admin-panel-content", "service-content-modal"]
|
||||
"classes": ["glass-surface", "glass-surface--detail", "nodedc-glass-detail-surface", "admin-panel-nav", "admin-panel-content", "service-content-modal"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-dropdown-surface", "nodedc-modal-field", "nodedc-settings-card"]
|
||||
"classes": ["nodedc-work-item-property-button", "nodedc-settings-card", "nodedc-modal-field", "nodedc-dropdown-surface"]
|
||||
}
|
||||
],
|
||||
"tokens": {
|
||||
"radius": "card | modal | control depending on scale",
|
||||
"blur": "panel | modal | dropdown",
|
||||
"blur": "control | panel | modal | dropdown | detail",
|
||||
"background": "dark matte glass gradient over rgba black",
|
||||
"border": "none or transparent soft glass only"
|
||||
},
|
||||
"rules": [
|
||||
"Never use hard outline as the main visual boundary.",
|
||||
"Use background opacity and blur to separate layers.",
|
||||
"Use background opacity, pseudo-layer glass, and backdrop blur to separate layers.",
|
||||
"For major shells use radius.card or radius.modal.",
|
||||
"For controls inside shells use radius.control."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,12 +38,26 @@
|
|||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "button",
|
||||
"spec": "components/button.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "glass-panel",
|
||||
"spec": "components/glass-panel.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "accent-contrast",
|
||||
"spec": "components/accent-contrast.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "dropdown-surface",
|
||||
"spec": "components/dropdown-surface.json",
|
||||
|
|
@ -102,6 +116,13 @@
|
|||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "delete-modal",
|
||||
"spec": "components/delete-modal.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "media-source-field",
|
||||
"spec": "components/media-source-field.json",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,17 @@
|
|||
{
|
||||
"id": "matte-glass",
|
||||
"severity": "error",
|
||||
"text": "Popup, dropdown, modal, sidebar, and settings surfaces use dark matte glass with blur, not plain transparency or light foreign backgrounds."
|
||||
"text": "Popup, dropdown, modal, sidebar, and settings surfaces use dark matte glass with blur, not plain transparency, monocolor fills, or light foreign backgrounds."
|
||||
},
|
||||
{
|
||||
"id": "accent-contrast",
|
||||
"severity": "error",
|
||||
"text": "Filled accent controls must use computed on-accent text from the accent-contrast utility."
|
||||
},
|
||||
{
|
||||
"id": "buttons-shared-module",
|
||||
"severity": "error",
|
||||
"text": "Text actions use the shared Button primitive with variant and surface props. Modal footer buttons must use surface='modal'."
|
||||
},
|
||||
{
|
||||
"id": "status-pills",
|
||||
|
|
@ -41,6 +51,16 @@
|
|||
"severity": "warning",
|
||||
"text": "Admin data screens use AdminTable anatomy: table-shell, toolbar, editable cells, circular actions, and shared status/date controls."
|
||||
},
|
||||
{
|
||||
"id": "delete-confirmation",
|
||||
"severity": "error",
|
||||
"text": "Every destructive delete/removal action must open NodeDcDeleteModal before mutating data."
|
||||
},
|
||||
{
|
||||
"id": "inline-delete-buttons",
|
||||
"severity": "error",
|
||||
"text": "Inline delete buttons inside editors and tables stay transparent by default and receive only neutral glass hover; red filled delete buttons are forbidden outside the confirmation modal."
|
||||
},
|
||||
{
|
||||
"id": "taskmanager-calendar",
|
||||
"severity": "warning",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
"tokensAndCss": [
|
||||
"src/styles/globals.css"
|
||||
],
|
||||
"colorUtilities": [
|
||||
"src/shared/lib/accentContrast.ts"
|
||||
],
|
||||
"topBar": [
|
||||
"src/widgets/top-bar/TopBar.tsx"
|
||||
],
|
||||
|
|
@ -25,6 +28,7 @@
|
|||
"src/shared/ui/PortalDropdown.tsx",
|
||||
"src/shared/nodedc-ui/Dropdown.tsx",
|
||||
"src/shared/nodedc-ui/Select.tsx",
|
||||
"src/shared/nodedc-ui/DeleteModal.tsx",
|
||||
"src/shared/nodedc-ui/Calendar.tsx",
|
||||
"src/shared/nodedc-ui/ProfileMenu.tsx",
|
||||
"src/shared/nodedc-ui/index.ts"
|
||||
|
|
@ -61,11 +65,20 @@
|
|||
"appHeader": [
|
||||
"plane-src/apps/web/core/components/core/app-header.tsx",
|
||||
"plane-src/apps/web/ce/components/common/extended-app-header.tsx"
|
||||
],
|
||||
"deleteModal": [
|
||||
"plane-src/packages/ui/src/modals/alert-modal.tsx",
|
||||
"plane-src/packages/ui/src/modals/modal-core.tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"crossProjectMapping": [
|
||||
{
|
||||
"component": "button",
|
||||
"launcher": ["Button", "button--accent", "Button surface='modal'", "ServiceContentModal footer", "EntityModalFoot"],
|
||||
"taskManager": ["nodedc-modal-primary-button", "nodedc-modal-secondary-button", "nodedc-modal-danger-button"]
|
||||
},
|
||||
{
|
||||
"component": "dropdown-surface",
|
||||
"launcher": ["NodeDcDropdown", "nodedc-ui-dropdown-surface", "nodedc-dropdown-surface"],
|
||||
|
|
@ -90,6 +103,16 @@
|
|||
"component": "admin-table",
|
||||
"launcher": ["ServicesSection", "ClientsSection", "UsersSection", "GroupsSection"],
|
||||
"taskManager": ["settings tables and external contour settings classes"]
|
||||
},
|
||||
{
|
||||
"component": "delete-modal",
|
||||
"launcher": ["NodeDcDeleteModal", "EntityModalFoot deleteConfig", "ServiceContentModal delete confirmation"],
|
||||
"taskManager": ["AlertModalCore", "ModalCore", "nodedc-glass-modal"]
|
||||
},
|
||||
{
|
||||
"component": "accent-contrast",
|
||||
"launcher": ["createNodedcAccentStyleVars", "getReadableNodedcTextRgb"],
|
||||
"taskManager": ["packages/utils/src/theme/nodedc-accent.ts", "--nodedc-on-accent-rgb"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
--nodedc-card-passive-rgb: 42 43 46;
|
||||
--nodedc-card-active-rgb: 195 255 102;
|
||||
--nodedc-on-accent-rgb: 11 17 23;
|
||||
--nodedc-component-accent-rgb: 247 248 244;
|
||||
--nodedc-component-on-accent-rgb: 11 17 23;
|
||||
--nodedc-radius-modal: 1.75rem;
|
||||
--nodedc-radius-card: 1.35rem;
|
||||
--nodedc-radius-control: 1.25rem;
|
||||
|
|
@ -10,6 +12,7 @@
|
|||
--nodedc-radius-calendar: 1.1rem;
|
||||
--nodedc-radius-circle: 999px;
|
||||
--nodedc-toolbar-pill-height: 2.5rem;
|
||||
--nodedc-modal-button-height: 2.75rem;
|
||||
--nodedc-control-ring: 2.92rem;
|
||||
--nodedc-control-inset: 5px;
|
||||
--nodedc-status-width: 8.65rem;
|
||||
|
|
@ -20,5 +23,5 @@
|
|||
--nodedc-dropdown-blur: 44px;
|
||||
--nodedc-panel-blur: 28px;
|
||||
--nodedc-modal-blur: 34px;
|
||||
--nodedc-detail-blur: 44px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"color": {
|
||||
"accentRgb": [195, 255, 102],
|
||||
"onAccentRgb": [11, 17, 23],
|
||||
"componentAccentRgb": [247, 248, 244],
|
||||
"componentOnAccentRgb": [11, 17, 23],
|
||||
"cardPassiveRgb": [42, 43, 46],
|
||||
"cardActiveRgb": [195, 255, 102],
|
||||
"surfaceBlack": "rgba(8, 8, 11, 0.9)",
|
||||
|
|
@ -45,6 +47,7 @@
|
|||
"dropdown": "44px",
|
||||
"panel": "28px",
|
||||
"modal": "34px",
|
||||
"detail": "44px",
|
||||
"control": "18px"
|
||||
},
|
||||
"shadow": {
|
||||
|
|
@ -76,4 +79,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@
|
|||
{
|
||||
"id": "service_nodedc",
|
||||
"slug": "nodedc",
|
||||
"title": "NodeDC",
|
||||
"title": "AGENT CORE",
|
||||
"subtitle": "Агентная платформа",
|
||||
"description": "Сборка, запуск и мониторинг агентных workflow.",
|
||||
"fullDescription": "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
|
||||
|
|
@ -240,7 +240,7 @@
|
|||
"authentikApplicationSlug": "nodedc",
|
||||
"authentikGroupName": "service-nodedc",
|
||||
"createdAt": "2026-04-01T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T17:59:10.713Z",
|
||||
"updatedAt": "2026-05-02T08:15:08.667Z",
|
||||
"coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg",
|
||||
"coverMediaKind": "image",
|
||||
"coverMediaSource": "file",
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
{
|
||||
"id": "service_task_manager",
|
||||
"slug": "task-manager",
|
||||
"title": "Task Manager",
|
||||
"title": "OPERATIONAL CORE",
|
||||
"subtitle": "Операционный слой",
|
||||
"description": "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
|
||||
"fullDescription": "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
|
||||
|
|
@ -266,7 +266,7 @@
|
|||
"authentikApplicationSlug": "task-manager",
|
||||
"authentikGroupName": "service-task-manager",
|
||||
"createdAt": "2026-04-01T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T17:59:10.713Z",
|
||||
"updatedAt": "2026-05-02T08:15:01.508Z",
|
||||
"coverImageUrl": "/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png",
|
||||
"coverMediaKind": "image",
|
||||
"coverMediaSource": "file",
|
||||
|
|
@ -275,7 +275,7 @@
|
|||
{
|
||||
"id": "service_1c",
|
||||
"slug": "1c-assistant",
|
||||
"title": "1C Assistant",
|
||||
"title": "1C AI ASSISTANT",
|
||||
"subtitle": "Бухгалтерский ассистент",
|
||||
"description": "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
|
||||
"fullDescription": "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
"authentikApplicationSlug": "1c-assistant",
|
||||
"authentikGroupName": "service-1c-assistant",
|
||||
"createdAt": "2026-04-01T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T17:59:10.713Z",
|
||||
"updatedAt": "2026-05-02T08:16:05.517Z",
|
||||
"coverImageUrl": "/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png",
|
||||
"coverMediaKind": "image",
|
||||
"coverMediaSource": "file",
|
||||
|
|
@ -297,7 +297,7 @@
|
|||
{
|
||||
"id": "service_tender",
|
||||
"slug": "tender-agent",
|
||||
"title": "Tender Agent",
|
||||
"title": "TENDER AI AGENT",
|
||||
"subtitle": "Госзакупки и тендеры",
|
||||
"description": "Поиск, анализ и подготовка тендерных решений.",
|
||||
"fullDescription": "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
|
||||
|
|
@ -310,7 +310,7 @@
|
|||
"authentikApplicationSlug": "tender-agent",
|
||||
"authentikGroupName": "service-tender-agent",
|
||||
"createdAt": "2026-04-03T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T17:59:10.713Z",
|
||||
"updatedAt": "2026-05-02T08:15:32.328Z",
|
||||
"coverImageUrl": "/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg",
|
||||
"coverMediaKind": "image",
|
||||
"coverMediaSource": "file",
|
||||
|
|
@ -323,12 +323,12 @@
|
|||
{
|
||||
"id": "service_digital_twin",
|
||||
"slug": "digital-twin",
|
||||
"title": "Digital Twin",
|
||||
"title": "DIGITAL TWIN MOSCOW",
|
||||
"subtitle": "3D и пространственные данные",
|
||||
"description": "Просмотр цифровых двойников, карт и объектных сцен.",
|
||||
"fullDescription": "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
|
||||
"url": "https://twin.handhdc.ru",
|
||||
"launchUrl": "https://twin.handhdc.ru/sso/launch",
|
||||
"launchUrl": "https://launch.dcserve.ru/",
|
||||
"accentColor": "#76E4F7",
|
||||
"fallbackGradient": "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
|
||||
"status": "active",
|
||||
|
|
@ -336,7 +336,11 @@
|
|||
"authentikApplicationSlug": "digital-twin",
|
||||
"authentikGroupName": "service-digital-twin",
|
||||
"createdAt": "2026-04-05T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T17:59:10.713Z"
|
||||
"updatedAt": "2026-05-02T08:55:28.665Z",
|
||||
"coverImageUrl": "/storage/uploads/1777711943125-691830c2-NODEDC_DT_MMAP.png",
|
||||
"coverMediaKind": "image",
|
||||
"coverMediaSource": "file",
|
||||
"coverMediaFileName": "1777711943125-691830c2-NODEDC_DT_MMAP.png"
|
||||
},
|
||||
{
|
||||
"id": "service_dm",
|
||||
|
|
@ -355,24 +359,6 @@
|
|||
"authentikGroupName": "service-digital-modules",
|
||||
"createdAt": "2026-04-10T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T17:59:10.713Z"
|
||||
},
|
||||
{
|
||||
"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-01T17:59:10.713Z"
|
||||
}
|
||||
],
|
||||
"grants": [
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
|
|
@ -204,6 +204,13 @@ export function LauncherApp() {
|
|||
}));
|
||||
}
|
||||
|
||||
function handleDeleteInvite(inviteId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
invites: current.invites.filter((invite) => invite.id !== inviteId),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRetrySync(syncId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
|
|
@ -275,6 +282,34 @@ export function LauncherApp() {
|
|||
}));
|
||||
}
|
||||
|
||||
function handleDeleteClient(clientId: string) {
|
||||
const nextClientId = data.clients.find((client) => client.id !== clientId)?.id ?? activeClientId;
|
||||
|
||||
setData((current) => {
|
||||
if (current.clients.length <= 1) return current;
|
||||
|
||||
const deletedGroupIds = new Set(current.groups.filter((group) => group.clientId === clientId).map((group) => group.id));
|
||||
|
||||
return {
|
||||
...current,
|
||||
clients: current.clients.filter((client) => client.id !== clientId),
|
||||
memberships: current.memberships.filter((membership) => membership.clientId !== clientId),
|
||||
groups: current.groups.filter((group) => group.clientId !== clientId),
|
||||
grants: current.grants.filter(
|
||||
(grant) =>
|
||||
!(grant.targetType === "client" && grant.targetId === clientId) &&
|
||||
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId))
|
||||
),
|
||||
invites: current.invites.filter((invite) => invite.clientId !== clientId),
|
||||
syncStatuses: current.syncStatuses.filter((sync) => sync.objectId !== clientId),
|
||||
};
|
||||
});
|
||||
|
||||
if (activeClientId === clientId) {
|
||||
setActiveClientId(nextClientId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateUser(userId: string, patch: Partial<LauncherUser>) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
|
|
@ -305,6 +340,27 @@ export function LauncherApp() {
|
|||
}));
|
||||
}
|
||||
|
||||
function handleDeleteMembership(membershipId: string) {
|
||||
setData((current) => {
|
||||
const membership = current.memberships.find((item) => item.id === membershipId);
|
||||
if (!membership) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
memberships: current.memberships.filter((item) => item.id !== membershipId),
|
||||
groups: current.groups.map((group) =>
|
||||
group.clientId === membership.clientId
|
||||
? {
|
||||
...group,
|
||||
memberIds: group.memberIds.filter((userId) => userId !== membership.userId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: group
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateGroup(clientId: string) {
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
|
|
@ -340,6 +396,14 @@ export function LauncherApp() {
|
|||
}));
|
||||
}
|
||||
|
||||
function handleDeleteGroup(groupId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
groups: current.groups.filter((group) => group.id !== groupId),
|
||||
grants: current.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId)),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleReorderServices(orderedServiceIds: string[]) {
|
||||
setData((current) => {
|
||||
const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10]));
|
||||
|
|
@ -443,13 +507,17 @@ export function LauncherApp() {
|
|||
onSetUserServiceAccess={handleSetUserServiceAccess}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onUpdateInvite={handleUpdateInvite}
|
||||
onDeleteInvite={handleDeleteInvite}
|
||||
onRetrySync={handleRetrySync}
|
||||
onCreateClient={handleCreateClient}
|
||||
onUpdateClient={handleUpdateClient}
|
||||
onDeleteClient={handleDeleteClient}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onUpdateMembership={handleUpdateMembership}
|
||||
onDeleteMembership={handleDeleteMembership}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onUpdateGroup={handleUpdateGroup}
|
||||
onDeleteGroup={handleDeleteGroup}
|
||||
onUpdateService={handleUpdateService}
|
||||
onReorderServices={handleReorderServices}
|
||||
onCreateService={handleCreateService}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
export type RgbTuple = readonly [number, number, number];
|
||||
|
||||
const DARK_TEXT_RGB: RgbTuple = [11, 17, 23];
|
||||
const LIGHT_TEXT_RGB: RgbTuple = [245, 247, 251];
|
||||
|
||||
function clampRgbChannel(value: number): number {
|
||||
return Math.min(Math.max(Math.round(value), 0), 255);
|
||||
}
|
||||
|
||||
export function formatRgbTuple(rgb: readonly number[]): string {
|
||||
return rgb.map(clampRgbChannel).join(" ");
|
||||
}
|
||||
|
||||
export function getNodedcRelativeLuminance(rgb: readonly number[]): number {
|
||||
const [r, g, b] = rgb.map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
export function getReadableNodedcTextRgb(rgb: readonly number[]): RgbTuple {
|
||||
return getNodedcRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB;
|
||||
}
|
||||
|
||||
export function createNodedcAccentStyleVars(
|
||||
accentRgb: RgbTuple,
|
||||
accentVarName = "--nodedc-component-accent-rgb",
|
||||
onAccentVarName = "--nodedc-component-on-accent-rgb"
|
||||
): CSSProperties {
|
||||
return {
|
||||
[accentVarName]: formatRgbTuple(accentRgb),
|
||||
[onAccentVarName]: formatRgbTuple(getReadableNodedcTextRgb(accentRgb)),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { createNodedcAccentStyleVars, type RgbTuple } from "../lib/accentContrast";
|
||||
import { Button } from "../ui/Button";
|
||||
|
||||
const DELETE_MODAL_ACCENT_RGB: RgbTuple = [247, 248, 244];
|
||||
|
||||
export interface NodeDcDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
submittingLabel?: string;
|
||||
accentRgb?: RgbTuple;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function NodeDcDeleteModal({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
cancelLabel = "Отмена",
|
||||
confirmLabel = "Удалить",
|
||||
submittingLabel = "Удаляем",
|
||||
accentRgb = DELETE_MODAL_ACCENT_RGB,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: NodeDcDeleteModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || typeof document === "undefined") return null;
|
||||
|
||||
async function handleConfirm() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
if (mountedRef.current) setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="nodedc-delete-modal-layer" role="presentation" onPointerDown={onClose}>
|
||||
<article
|
||||
className="nodedc-delete-modal nodedc-glass-detail-surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nodedc-delete-modal-title"
|
||||
style={createNodedcAccentStyleVars(accentRgb, "--nodedc-delete-accent-rgb", "--nodedc-delete-on-accent-rgb")}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="nodedc-delete-modal__body">
|
||||
<span className="nodedc-delete-modal__icon" aria-hidden="true">
|
||||
<AlertTriangle size={21} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="nodedc-delete-modal__copy">
|
||||
<h3 id="nodedc-delete-modal-title">{title}</h3>
|
||||
<div className="nodedc-delete-modal__text">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nodedc-delete-modal__foot">
|
||||
<Button variant="secondary" surface="modal" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant="accent" surface="modal" accentRgb={accentRgb} type="button" onClick={handleConfirm} disabled={isSubmitting}>
|
||||
{isSubmitting ? submittingLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { NodeDcDropdown } from "./Dropdown";
|
||||
export { NodeDcDeleteModal, type NodeDcDeleteModalProps } from "./DeleteModal";
|
||||
export {
|
||||
NodeDcDateField,
|
||||
NodeDcCalendar,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { createNodedcAccentStyleVars, type RgbTuple } from "../lib/accentContrast";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost" | "accent";
|
||||
export type ButtonSurface = "default" | "modal";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
surface?: ButtonSurface;
|
||||
accentRgb?: RgbTuple;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = "secondary", icon, className, children, ...props }: ButtonProps) {
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
surface = "default",
|
||||
accentRgb,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const accentStyle = accentRgb
|
||||
? createNodedcAccentStyleVars(accentRgb, "--nodedc-button-accent-rgb", "--nodedc-button-on-accent-rgb")
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button className={cn("button", `button--${variant}`, className)} {...props}>
|
||||
<button
|
||||
className={cn("button", `button--${variant}`, className)}
|
||||
data-surface={surface}
|
||||
style={accentStyle ? { ...accentStyle, ...style } : style}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
{children ? <span>{children}</span> : null}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { cn } from "../lib/cn";
|
|||
|
||||
interface GlassProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
tone?: "default" | "strong" | "soft";
|
||||
tone?: "default" | "strong" | "soft" | "detail";
|
||||
}
|
||||
|
||||
export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@
|
|||
--nodedc-card-passive-rgb: 42 43 46;
|
||||
--nodedc-card-active-rgb: 195 255 102;
|
||||
--nodedc-on-accent-rgb: 11 17 23;
|
||||
--nodedc-component-accent-rgb: 247 248 244;
|
||||
--nodedc-component-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;
|
||||
--launcher-modal-button-height: 2.75rem;
|
||||
--surface-base: rgba(8, 8, 11, 0.78);
|
||||
--surface-strong: rgba(8, 8, 11, 0.9);
|
||||
--surface-soft: rgba(255, 255, 255, 0.08);
|
||||
|
|
@ -1060,6 +1063,9 @@ code {
|
|||
}
|
||||
|
||||
.button {
|
||||
--nodedc-button-accent-rgb: var(--nodedc-component-accent-rgb);
|
||||
--nodedc-button-on-accent-rgb: var(--nodedc-component-on-accent-rgb);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -1094,8 +1100,45 @@ code {
|
|||
}
|
||||
|
||||
.button--danger {
|
||||
background: rgba(255, 116, 116, 0.16);
|
||||
color: #ffd5d5;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.button--danger:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.button--accent {
|
||||
background: rgb(var(--nodedc-button-accent-rgb));
|
||||
color: rgb(var(--nodedc-button-on-accent-rgb));
|
||||
}
|
||||
|
||||
.button--accent:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-button-accent-rgb)) 84%, rgba(255, 255, 255, 0.72));
|
||||
color: rgb(var(--nodedc-button-on-accent-rgb));
|
||||
}
|
||||
|
||||
.button--accent * {
|
||||
color: rgb(var(--nodedc-button-on-accent-rgb));
|
||||
}
|
||||
|
||||
.button[data-surface="modal"] {
|
||||
min-height: var(--launcher-modal-button-height);
|
||||
}
|
||||
|
||||
.button[data-surface="modal"].button--secondary,
|
||||
.button[data-surface="modal"].button--danger,
|
||||
.button[data-surface="modal"].button--ghost {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.button[data-surface="modal"].button--secondary:hover:not(:disabled),
|
||||
.button[data-surface="modal"].button--danger:hover:not(:disabled),
|
||||
.button[data-surface="modal"].button--ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
|
|
@ -2149,6 +2192,131 @@ code {
|
|||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nodedc-delete-modal-layer {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.4rem;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.nodedc-glass-detail-surface,
|
||||
.glass-surface--detail,
|
||||
.glass-card--detail {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: rgba(7, 7, 10, 0.54);
|
||||
box-shadow:
|
||||
0 34px 120px rgba(0, 0, 0, 0.64),
|
||||
0 10px 32px rgba(0, 0, 0, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.045);
|
||||
backdrop-filter: blur(40px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.12);
|
||||
}
|
||||
|
||||
.nodedc-glass-detail-surface::before,
|
||||
.glass-surface--detail::before,
|
||||
.glass-card--detail::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.014) 100%),
|
||||
rgba(5, 5, 8, 0.58);
|
||||
backdrop-filter: blur(44px) saturate(1.16);
|
||||
-webkit-backdrop-filter: blur(44px) saturate(1.16);
|
||||
}
|
||||
|
||||
.nodedc-glass-detail-surface::after,
|
||||
.glass-surface--detail::after,
|
||||
.glass-card--detail::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.035), transparent 44%, rgba(255, 255, 255, 0.018));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-glass-detail-surface > *,
|
||||
.glass-surface--detail > *,
|
||||
.glass-card--detail > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal {
|
||||
width: min(34rem, calc(100vw - 2.8rem));
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-modal);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__body {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__icon {
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(var(--nodedc-delete-accent-rgb), 0.08);
|
||||
color: rgb(var(--nodedc-delete-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__copy {
|
||||
min-width: 0;
|
||||
padding-top: 0.08rem;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__copy h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__text {
|
||||
margin-top: 0.42rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__text strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem 1.25rem 1.15rem;
|
||||
}
|
||||
|
||||
.nodedc-delete-modal .button {
|
||||
min-width: 8.25rem;
|
||||
border-radius: var(--launcher-radius-control);
|
||||
}
|
||||
|
||||
.service-content-modal {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
|
@ -2363,16 +2531,6 @@ code {
|
|||
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);
|
||||
}
|
||||
|
||||
.admin-token-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ import {
|
|||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||
import { cn } from "../../shared/lib/cn";
|
||||
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
||||
import { NodeDcDateField, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
|
||||
import { NodeDcDateField, NodeDcDeleteModal, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
|
||||
import { Button, IconButton } from "../../shared/ui/Button";
|
||||
import { GlassSurface } from "../../shared/ui/Glass";
|
||||
|
||||
|
|
@ -118,13 +118,17 @@ export function AdminOverlay({
|
|||
onSetUserServiceAccess,
|
||||
onCreateInvite,
|
||||
onUpdateInvite,
|
||||
onDeleteInvite,
|
||||
onRetrySync,
|
||||
onCreateClient,
|
||||
onUpdateClient,
|
||||
onDeleteClient,
|
||||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
onDeleteMembership,
|
||||
onCreateGroup,
|
||||
onUpdateGroup,
|
||||
onDeleteGroup,
|
||||
onUpdateService,
|
||||
onReorderServices,
|
||||
onCreateService,
|
||||
|
|
@ -137,13 +141,17 @@ export function AdminOverlay({
|
|||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
|
||||
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
||||
onDeleteInvite: (inviteId: string) => void;
|
||||
onRetrySync: (syncId: string) => void;
|
||||
onCreateClient: () => void;
|
||||
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
||||
onDeleteClient: (clientId: string) => void;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
onDeleteMembership: (membershipId: string) => void;
|
||||
onCreateGroup: (clientId: string) => void;
|
||||
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
|
||||
onDeleteGroup: (groupId: string) => void;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||||
onReorderServices: (orderedServiceIds: string[]) => void;
|
||||
onCreateService: () => void;
|
||||
|
|
@ -155,7 +163,9 @@ export function AdminOverlay({
|
|||
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
||||
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
||||
|
||||
const scopedClientId = isRoot ? selectedClientId : activeClientId;
|
||||
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
|
||||
const selectedClientExists = data.clients.some((client) => client.id === selectedClientId);
|
||||
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
|
||||
const currentClient = getClient(data, scopedClientId);
|
||||
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
|
||||
const selectedAccessCell =
|
||||
|
|
@ -163,6 +173,13 @@ export function AdminOverlay({
|
|||
accessMatrix.cells[0] ??
|
||||
null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isRoot && !selectedClientExists && data.clients.length) {
|
||||
setSelectedClientId(data.clients[0].id);
|
||||
setSelectedCell(null);
|
||||
}
|
||||
}, [data.clients, isRoot, selectedClientExists]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
|
|
@ -250,7 +267,7 @@ export function AdminOverlay({
|
|||
<div className="admin-panel-content__body">
|
||||
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
|
||||
{activeSection === "clients" && isRoot ? (
|
||||
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} />
|
||||
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} />
|
||||
) : null}
|
||||
{activeSection === "users" ? (
|
||||
<UsersSection
|
||||
|
|
@ -259,10 +276,17 @@ export function AdminOverlay({
|
|||
isRoot={isRoot}
|
||||
onUpdateUser={onUpdateUser}
|
||||
onUpdateMembership={onUpdateMembership}
|
||||
onDeleteMembership={onDeleteMembership}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "groups" ? (
|
||||
<GroupsSection data={data} clientId={scopedClientId} onCreateGroup={onCreateGroup} onUpdateGroup={onUpdateGroup} />
|
||||
<GroupsSection
|
||||
data={data}
|
||||
clientId={scopedClientId}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onUpdateGroup={onUpdateGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "services" && isRoot ? (
|
||||
<ServicesSection
|
||||
|
|
@ -289,6 +313,7 @@ export function AdminOverlay({
|
|||
actorUserId={me.user.id}
|
||||
onCreateInvite={onCreateInvite}
|
||||
onUpdateInvite={onUpdateInvite}
|
||||
onDeleteInvite={onDeleteInvite}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
||||
|
|
@ -348,10 +373,12 @@ function ClientsSection({
|
|||
data,
|
||||
onCreateClient,
|
||||
onUpdateClient,
|
||||
onDeleteClient,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
onCreateClient: () => void;
|
||||
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
||||
onDeleteClient: (clientId: string) => void;
|
||||
}) {
|
||||
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||
const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null;
|
||||
|
|
@ -453,6 +480,11 @@ function ClientsSection({
|
|||
onUpdateClient(editingClient.id, patch);
|
||||
setEditingClientId(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteClient(editingClient.id);
|
||||
setEditingClientId(null);
|
||||
}}
|
||||
canDelete={data.clients.length > 1}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
@ -465,12 +497,14 @@ function UsersSection({
|
|||
isRoot,
|
||||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
onDeleteMembership,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientId: string;
|
||||
isRoot: boolean;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
onDeleteMembership: (membershipId: string) => void;
|
||||
}) {
|
||||
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
|
||||
const rows = isRoot
|
||||
|
|
@ -579,6 +613,10 @@ function UsersSection({
|
|||
onUpdateMembership(editingRow.membership.id, membershipPatch);
|
||||
setEditingMembershipId(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteMembership(editingRow.membership.id);
|
||||
setEditingMembershipId(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
@ -590,11 +628,13 @@ function GroupsSection({
|
|||
clientId,
|
||||
onCreateGroup,
|
||||
onUpdateGroup,
|
||||
onDeleteGroup,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientId: string;
|
||||
onCreateGroup: (clientId: string) => void;
|
||||
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
|
||||
onDeleteGroup: (groupId: string) => void;
|
||||
}) {
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const groups = data.groups.filter((group) => group.clientId === clientId);
|
||||
|
|
@ -667,6 +707,10 @@ function GroupsSection({
|
|||
onUpdateGroup(editingGroup.id, patch);
|
||||
setEditingGroupId(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteGroup(editingGroup.id);
|
||||
setEditingGroupId(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
@ -747,6 +791,7 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
|
|||
];
|
||||
|
||||
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
||||
const modalActionAccentRgb = [247, 248, 244] as const;
|
||||
|
||||
function ServicesSection({
|
||||
data,
|
||||
|
|
@ -1131,6 +1176,7 @@ function ServiceContentModal({
|
|||
const [draft, setDraft] = useState<Service>(service);
|
||||
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
|
||||
const [storageError, setStorageError] = useState<string | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(service);
|
||||
|
|
@ -1265,15 +1311,17 @@ function ServiceContentModal({
|
|||
</div>
|
||||
|
||||
<div className="service-content-modal__foot">
|
||||
<Button variant="secondary" type="button" onClick={onClose}>
|
||||
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<div className="service-content-modal__foot-actions">
|
||||
<Button variant="danger" type="button" icon={<Trash2 size={16} />} onClick={onDelete}>
|
||||
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
|
||||
Удалить
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="accent"
|
||||
surface="modal"
|
||||
accentRgb={modalActionAccentRgb}
|
||||
type="button"
|
||||
disabled={uploadingSlot !== null}
|
||||
icon={<Save size={16} />}
|
||||
|
|
@ -1300,6 +1348,20 @@ function ServiceContentModal({
|
|||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<NodeDcDeleteModal
|
||||
isOpen={deleteOpen}
|
||||
title="Удалить витрину"
|
||||
description={
|
||||
<>
|
||||
Витрина <strong>{service.title}</strong> будет удалена из каталога, нижней панели и матрицы доступов.
|
||||
</>
|
||||
}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
setDeleteOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1308,10 +1370,14 @@ function ClientEditorModal({
|
|||
client,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
canDelete,
|
||||
}: {
|
||||
client: Client;
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<Client>) => void;
|
||||
onDelete: () => void;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Client>(client);
|
||||
|
||||
|
|
@ -1394,7 +1460,24 @@ function ClientEditorModal({
|
|||
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
|
||||
</label>
|
||||
</div>
|
||||
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} />
|
||||
<EntityModalFoot
|
||||
onClose={onClose}
|
||||
onSave={() => onSave(draft)}
|
||||
deleteConfig={
|
||||
canDelete
|
||||
? {
|
||||
label: "Удалить",
|
||||
title: "Удалить компанию",
|
||||
description: (
|
||||
<>
|
||||
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
|
||||
</>
|
||||
),
|
||||
onConfirm: onDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1406,12 +1489,14 @@ function UserEditorModal({
|
|||
client,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
user: LauncherUser;
|
||||
membership: ClientMembership;
|
||||
client: Client;
|
||||
onClose: () => void;
|
||||
onSave: (userPatch: Partial<LauncherUser>, membershipPatch: Partial<ClientMembership>) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [userDraft, setUserDraft] = useState<LauncherUser>(user);
|
||||
const [membershipDraft, setMembershipDraft] = useState<ClientMembership>(membership);
|
||||
|
|
@ -1479,7 +1564,20 @@ function UserEditorModal({
|
|||
<textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} />
|
||||
</label>
|
||||
</div>
|
||||
<EntityModalFoot onClose={onClose} onSave={() => onSave(userDraft, membershipDraft)} />
|
||||
<EntityModalFoot
|
||||
onClose={onClose}
|
||||
onSave={() => onSave(userDraft, membershipDraft)}
|
||||
deleteConfig={{
|
||||
label: "Удалить",
|
||||
title: "Удалить участника",
|
||||
description: (
|
||||
<>
|
||||
Участник <strong>{user.name}</strong> будет удален из клиента <strong>{client.name}</strong>. Глобальный профиль пользователя останется в демо-справочнике.
|
||||
</>
|
||||
),
|
||||
onConfirm: onDelete,
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1490,11 +1588,13 @@ function GroupEditorModal({
|
|||
users,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
group: ClientGroup;
|
||||
users: LauncherUser[];
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<ClientGroup>) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ClientGroup>(group);
|
||||
|
||||
|
|
@ -1543,7 +1643,20 @@ function GroupEditorModal({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} />
|
||||
<EntityModalFoot
|
||||
onClose={onClose}
|
||||
onSave={() => onSave(draft)}
|
||||
deleteConfig={{
|
||||
label: "Удалить",
|
||||
title: "Удалить группу",
|
||||
description: (
|
||||
<>
|
||||
Группа <strong>{group.name}</strong> и привязанные к ней гранты сервисов будут удалены.
|
||||
</>
|
||||
),
|
||||
onConfirm: onDelete,
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1563,16 +1676,52 @@ function EntityModalHead({ eyebrow, title, onClose }: { eyebrow: string; title:
|
|||
);
|
||||
}
|
||||
|
||||
function EntityModalFoot({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||
function EntityModalFoot({
|
||||
onClose,
|
||||
onSave,
|
||||
deleteConfig,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
deleteConfig?: {
|
||||
label: string;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="service-content-modal__foot">
|
||||
<Button variant="secondary" type="button" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" type="button" icon={<Save size={16} />} onClick={onSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className="service-content-modal__foot">
|
||||
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<div className="service-content-modal__foot-actions">
|
||||
{deleteConfig ? (
|
||||
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
|
||||
{deleteConfig.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="accent" surface="modal" accentRgb={modalActionAccentRgb} type="button" icon={<Save size={16} />} onClick={onSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{deleteConfig ? (
|
||||
<NodeDcDeleteModal
|
||||
isOpen={deleteOpen}
|
||||
title={deleteConfig.title}
|
||||
description={deleteConfig.description}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
setDeleteOpen(false);
|
||||
deleteConfig.onConfirm();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1821,16 +1970,20 @@ function InvitesSection({
|
|||
actorUserId,
|
||||
onCreateInvite,
|
||||
onUpdateInvite,
|
||||
onDeleteInvite,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientId: string;
|
||||
actorUserId: string;
|
||||
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
|
||||
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
||||
onDeleteInvite: (inviteId: string) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<ClientMembershipRole>("member");
|
||||
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
|
||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
|
||||
const actor = getUser(data, actorUserId);
|
||||
|
||||
function handleCreateInvite() {
|
||||
|
|
@ -1884,6 +2037,7 @@ function InvitesSection({
|
|||
<th>Статус</th>
|
||||
<th>Ссылка</th>
|
||||
<th>Истекает</th>
|
||||
<th aria-label="Удаление" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -1938,11 +2092,35 @@ function InvitesSection({
|
|||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Удалить инвайт ${invite.email}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => setDeleteInviteId(invite.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GlassSurface>
|
||||
<NodeDcDeleteModal
|
||||
isOpen={Boolean(deletingInvite)}
|
||||
title="Удалить инвайт"
|
||||
description={
|
||||
<>
|
||||
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
|
||||
</>
|
||||
}
|
||||
onClose={() => setDeleteInviteId(null)}
|
||||
onConfirm={() => {
|
||||
if (deletingInvite) onDeleteInvite(deletingInvite.id);
|
||||
setDeleteInviteId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue