diff --git a/dc-ui-guideline/components/accent-contrast.json b/dc-ui-guideline/components/accent-contrast.json new file mode 100644 index 0000000..1551a75 --- /dev/null +++ b/dc-ui-guideline/components/accent-contrast.json @@ -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." + ] +} diff --git a/dc-ui-guideline/components/button.json b/dc-ui-guideline/components/button.json new file mode 100644 index 0000000..768e3ec --- /dev/null +++ b/dc-ui-guideline/components/button.json @@ -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." + ] +} diff --git a/dc-ui-guideline/components/delete-modal.json b/dc-ui-guideline/components/delete-modal.json new file mode 100644 index 0000000..2a80e20 --- /dev/null +++ b/dc-ui-guideline/components/delete-modal.json @@ -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" + ] +} diff --git a/dc-ui-guideline/components/entity-modal.json b/dc-ui-guideline/components/entity-modal.json index 110ffa2..d1a3708 100644 --- a/dc-ui-guideline/components/entity-modal.json +++ b/dc-ui-guideline/components/entity-modal.json @@ -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." ] } - diff --git a/dc-ui-guideline/components/glass-panel.json b/dc-ui-guideline/components/glass-panel.json index 4795cff..10a79e7 100644 --- a/dc-ui-guideline/components/glass-panel.json +++ b/dc-ui-guideline/components/glass-panel.json @@ -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." ] } - diff --git a/dc-ui-guideline/registry.json b/dc-ui-guideline/registry.json index 669c0ad..c041413 100644 --- a/dc-ui-guideline/registry.json +++ b/dc-ui-guideline/registry.json @@ -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", diff --git a/dc-ui-guideline/rules/ui-rules.json b/dc-ui-guideline/rules/ui-rules.json index fed2d60..29ea003 100644 --- a/dc-ui-guideline/rules/ui-rules.json +++ b/dc-ui-guideline/rules/ui-rules.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", diff --git a/dc-ui-guideline/sources/source-map.json b/dc-ui-guideline/sources/source-map.json index 394f99b..0e8c371 100644 --- a/dc-ui-guideline/sources/source-map.json +++ b/dc-ui-guideline/sources/source-map.json @@ -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"] } ] } diff --git a/dc-ui-guideline/tokens/nodedc.tokens.css b/dc-ui-guideline/tokens/nodedc.tokens.css index c04a380..672412e 100644 --- a/dc-ui-guideline/tokens/nodedc.tokens.css +++ b/dc-ui-guideline/tokens/nodedc.tokens.css @@ -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; } - diff --git a/dc-ui-guideline/tokens/nodedc.tokens.json b/dc-ui-guideline/tokens/nodedc.tokens.json index 03c6859..6c7f96a 100644 --- a/dc-ui-guideline/tokens/nodedc.tokens.json +++ b/dc-ui-guideline/tokens/nodedc.tokens.json @@ -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 @@ } } } - diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 6b3ee39..190673c 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -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": [ diff --git a/public/storage/uploads/1777710004658-e3c94f1a-MOSCOWMAP_GAUSS-Cover.jpg b/public/storage/uploads/1777710004658-e3c94f1a-MOSCOWMAP_GAUSS-Cover.jpg new file mode 100644 index 0000000..8eef27b Binary files /dev/null and b/public/storage/uploads/1777710004658-e3c94f1a-MOSCOWMAP_GAUSS-Cover.jpg differ diff --git a/public/storage/uploads/1777711943125-691830c2-NODEDC_DT_MMAP.png b/public/storage/uploads/1777711943125-691830c2-NODEDC_DT_MMAP.png new file mode 100644 index 0000000..6686870 Binary files /dev/null and b/public/storage/uploads/1777711943125-691830c2-NODEDC_DT_MMAP.png differ diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index f9087d7..515eeba 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -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) { 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} diff --git a/src/shared/lib/accentContrast.ts b/src/shared/lib/accentContrast.ts new file mode 100644 index 0000000..cc3b55c --- /dev/null +++ b/src/shared/lib/accentContrast.ts @@ -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; +} diff --git a/src/shared/nodedc-ui/DeleteModal.tsx b/src/shared/nodedc-ui/DeleteModal.tsx new file mode 100644 index 0000000..f4ca939 --- /dev/null +++ b/src/shared/nodedc-ui/DeleteModal.tsx @@ -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; +} + +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( +
+
event.stopPropagation()} + > +
+ +
+

{title}

+
{description}
+
+
+
+ + +
+
+
, + document.body + ); +} diff --git a/src/shared/nodedc-ui/index.ts b/src/shared/nodedc-ui/index.ts index ada7a98..f037f44 100644 --- a/src/shared/nodedc-ui/index.ts +++ b/src/shared/nodedc-ui/index.ts @@ -1,4 +1,5 @@ export { NodeDcDropdown } from "./Dropdown"; +export { NodeDcDeleteModal, type NodeDcDeleteModalProps } from "./DeleteModal"; export { NodeDcDateField, NodeDcCalendar, diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx index 77b9e52..1357acc 100644 --- a/src/shared/ui/Button.tsx +++ b/src/shared/ui/Button.tsx @@ -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 { 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 ( - diff --git a/src/shared/ui/Glass.tsx b/src/shared/ui/Glass.tsx index f3cb21b..fa407e5 100644 --- a/src/shared/ui/Glass.tsx +++ b/src/shared/ui/Glass.tsx @@ -3,7 +3,7 @@ import { cn } from "../lib/cn"; interface GlassProps extends HTMLAttributes { children: ReactNode; - tone?: "default" | "strong" | "soft"; + tone?: "default" | "strong" | "soft" | "detail"; } export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) { diff --git a/src/styles/globals.css b/src/styles/globals.css index a87c710..2052563 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 289be8b..90acc67 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -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) => void; onUpdateInvite: (inviteId: string, patch: Partial) => void; + onDeleteInvite: (inviteId: string) => void; onRetrySync: (syncId: string) => void; onCreateClient: () => void; onUpdateClient: (clientId: string, patch: Partial) => void; + onDeleteClient: (clientId: string) => void; onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; + onDeleteMembership: (membershipId: string) => void; onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; + onDeleteGroup: (groupId: string) => void; onUpdateService: (serviceId: string, patch: Partial) => 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({
{activeSection === "overview" ? : null} {activeSection === "clients" && isRoot ? ( - + ) : null} {activeSection === "users" ? ( ) : null} {activeSection === "groups" ? ( - + ) : null} {activeSection === "services" && isRoot ? ( ) : null} {activeSection === "sync" ? : null} @@ -348,10 +373,12 @@ function ClientsSection({ data, onCreateClient, onUpdateClient, + onDeleteClient, }: { data: LauncherData; onCreateClient: () => void; onUpdateClient: (clientId: string, patch: Partial) => void; + onDeleteClient: (clientId: string) => void; }) { const [editingClientId, setEditingClientId] = useState(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) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; + onDeleteMembership: (membershipId: string) => void; }) { const [editingMembershipId, setEditingMembershipId] = useState(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) => void; + onDeleteGroup: (groupId: string) => void; }) { const [editingGroupId, setEditingGroupId] = useState(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> ]; 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); const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null); const [storageError, setStorageError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); useEffect(() => { setDraft(service); @@ -1265,15 +1311,17 @@ function ServiceContentModal({
-
-
+ + Витрина {service.title} будет удалена из каталога, нижней панели и матрицы доступов. + + } + onClose={() => setDeleteOpen(false)} + onConfirm={() => { + setDeleteOpen(false); + onDelete(); + }} + /> ); } @@ -1308,10 +1370,14 @@ function ClientEditorModal({ client, onClose, onSave, + onDelete, + canDelete, }: { client: Client; onClose: () => void; onSave: (patch: Partial) => void; + onDelete: () => void; + canDelete: boolean; }) { const [draft, setDraft] = useState(client); @@ -1394,7 +1460,24 @@ function ClientEditorModal({