Compare commits
10 Commits
e8c6e76885
...
b221ccb83e
| Author | SHA1 | Date |
|---|---|---|
|
|
b221ccb83e | |
|
|
de0a0d2948 | |
|
|
5e86047a02 | |
|
|
c6e1de6345 | |
|
|
17e007f49d | |
|
|
adad0bd344 | |
|
|
69eb5260b0 | |
|
|
c99c91c826 | |
|
|
85aa322990 | |
|
|
db0eabe7d7 |
|
|
@ -0,0 +1,8 @@
|
|||
# Optional: override the platform env file used by the local launcher BFF.
|
||||
# By default it auto-loads ../../NODEDC/platform/infra/.env from this repo.
|
||||
NODEDC_PLATFORM_ENV=../../NODEDC/platform/infra/.env
|
||||
|
||||
# Optional local overrides.
|
||||
PORT=5173
|
||||
LAUNCHER_BASE_URL=http://launcher.local.nodedc
|
||||
TASK_BASE_URL=http://task.local.nodedc
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# NODE.DC UI Guideline
|
||||
|
||||
This folder is the first local source of truth for reusable NODE.DC UI components.
|
||||
|
||||
It is intentionally MCP-friendly:
|
||||
- `registry.json` lists every known component contract.
|
||||
- `tokens/nodedc.tokens.json` contains machine-readable design tokens.
|
||||
- `tokens/nodedc.tokens.css` mirrors runtime CSS variables.
|
||||
- `components/*.json` describe component anatomy, states, rules, and source references.
|
||||
- `recipes/*.json` describe larger UI compositions.
|
||||
- `sources/source-map.json` links this launcher and NODEDC Task Manager implementation files.
|
||||
|
||||
The codebase remains the source of truth for implementation. This folder is the index and contract layer that an MCP server or coding agent can read before generating UI.
|
||||
|
||||
## Operating Rule
|
||||
|
||||
Before creating a new NODE.DC UI element, check `registry.json`.
|
||||
|
||||
If a matching component exists, use or extend it. Do not create a local one-off dropdown, modal, button, status pill, table, calendar, or navigation panel.
|
||||
|
||||
|
|
@ -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,41 @@
|
|||
{
|
||||
"id": "action-dropdown",
|
||||
"name": "ActionDropdown",
|
||||
"kind": "component",
|
||||
"status": "stable-reference",
|
||||
"summary": "Shared action menu for ellipsis buttons and command lists. Used instead of local card menus.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"exports": ["ActionDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "HDROPDOWN-CANON.md",
|
||||
"section": "Action dropdown"
|
||||
}
|
||||
],
|
||||
"propsContract": {
|
||||
"items": "Array of action menu items with key, icon, title, description, disabled, action, nestedMenuItems.",
|
||||
"button": "Optional custom trigger.",
|
||||
"buttonAsChild": "Allows using the provided trigger element as anchor.",
|
||||
"placement": "Popper placement.",
|
||||
"portalElement": "Defaults to document.body.",
|
||||
"menuClassName": "Optional width/padding adjustment."
|
||||
},
|
||||
"behaviorContract": [
|
||||
"Uses real trigger element.",
|
||||
"Uses fixed Popper strategy.",
|
||||
"Uses offset [0, 8].",
|
||||
"Uses flip and preventOverflow.",
|
||||
"Uses portal to document.body by default.",
|
||||
"Stops trigger event propagation so cards do not open when the menu opens."
|
||||
],
|
||||
"rules": [
|
||||
"Do not build card ellipsis menus with local isOpen and absolute positioning.",
|
||||
"Menu item visual rendering lives inside ActionDropdown.",
|
||||
"Nested items are allowed, but still use the same dropdown shell."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"id": "admin-side-nav",
|
||||
"name": "AdminSideNav",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Left admin panel navigation with full-width rounded rows, circular icons, client selector, close button, and role footer.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"classes": ["admin-panel-nav", "admin-panel-client-select", "admin-panel-nav-item", "admin-panel-role"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["admin-panel-nav", "admin-panel-nav-item", "admin-panel-nav-item__icon", "admin-panel-close"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"panel shell",
|
||||
"head: eyebrow, title, close action",
|
||||
"client selector row",
|
||||
"nav list",
|
||||
"role footer"
|
||||
],
|
||||
"geometry": {
|
||||
"width": "clamp(20.75rem, 19.5vw, 22rem)",
|
||||
"panelPadding": "1.1rem",
|
||||
"rowHeight": "controlRing + 2 * controlInset",
|
||||
"iconSize": "controlRing",
|
||||
"edgeInset": "5px"
|
||||
},
|
||||
"states": {
|
||||
"inactive": "muted text, darker icon circle, partial opacity",
|
||||
"active": "white icon circle and brighter row surface",
|
||||
"hover": "slightly brighter row surface"
|
||||
},
|
||||
"rules": [
|
||||
"Rows stretch to panel edges by counteracting panel padding.",
|
||||
"Active nav icon is white, not green.",
|
||||
"Close button is anchored at panel radius with 5px inset.",
|
||||
"Client selector uses the same row/circle geometry as nav rows."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"id": "admin-table",
|
||||
"name": "AdminTable",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Admin data table shell used for services, clients, users, groups, invites, sync, and audit.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"functions": ["ServicesSection", "ClientsSection", "UsersSection", "GroupsSection", "InvitesSection", "SyncSection", "AuditSection"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["table-shell", "admin-data-table", "services-admin-table", "admin-table-input"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"table-shell glass surface",
|
||||
"table-toolbar",
|
||||
"thead labels",
|
||||
"tbody rows",
|
||||
"inline editable cells",
|
||||
"StatusControl cells",
|
||||
"NodeDcDateField cells",
|
||||
"CircleActionButton action cells"
|
||||
],
|
||||
"visualContract": {
|
||||
"background": "same family as inactive admin nav row, not a foreign panel",
|
||||
"fontSize": "0.82rem table; compact labels 0.66rem",
|
||||
"cellVerticalAlign": "middle",
|
||||
"rowAction": "circular edit button at right"
|
||||
},
|
||||
"rules": [
|
||||
"Create action is a solid circular plus, not a wide text CTA.",
|
||||
"Inline editable cells use transparent input surfaces that brighten on hover/focus.",
|
||||
"Status cells use StatusControl.",
|
||||
"Date cells use CalendarPopover through NodeDcDateField.",
|
||||
"Drag handles move full rows, not detached ghosts."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"id": "app-header",
|
||||
"name": "NodeDcAppHeader",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Top application header with logo, centered navigation group, workspace mark, and profile cluster.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/top-bar/TopBar.tsx",
|
||||
"functions": ["TopBar"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Select.tsx",
|
||||
"exports": ["NodeDcSelect"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/ProfileMenu.tsx",
|
||||
"exports": ["NodeDcProfileMenu"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["nodedc-expanded-toolbar", "nodedc-expanded-nav-group", "nodedc-expanded-nav-button", "nodedc-expanded-user-group"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/core/components/core/app-header.tsx"
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"left brand logo",
|
||||
"center workspace mark",
|
||||
"center segmented nav",
|
||||
"right profile pill with optional notification icon and avatar"
|
||||
],
|
||||
"geometry": {
|
||||
"shellPadding": "1.25rem top/right/left in launcher",
|
||||
"pillHeight": "3rem in launcher expanded toolbar",
|
||||
"brandLogo": "visual size follows Task Manager reference, not text-only brand"
|
||||
},
|
||||
"rules": [
|
||||
"Header is monocolor black/dark, not gradient decoration.",
|
||||
"Active top nav segment is white filled pill.",
|
||||
"Profile avatar diameter must match reference topbar avatar.",
|
||||
"Launcher and Task Manager headers must remain visually identical where routes overlap."
|
||||
]
|
||||
}
|
||||
|
|
@ -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,78 @@
|
|||
{
|
||||
"id": "calendar-popover",
|
||||
"name": "CalendarPopover",
|
||||
"kind": "component",
|
||||
"status": "stable-reference",
|
||||
"summary": "Date picker popover based on Task Manager DateDropdown and nodedc-calendar-shell styling.",
|
||||
"implementation": {
|
||||
"baseLibrary": "react-day-picker@9.5.0",
|
||||
"localePackage": "date-fns@4.1.0",
|
||||
"launcherComponent": "NodeDcDateField renders NodeDcDropdown + NodeDcCalendar; NodeDcCalendar delegates the month grid/range math to DayPicker instead of a hand-written grid.",
|
||||
"isolation": "Calendar logic and styling live in src/shared/nodedc-ui/Calendar.tsx and src/shared/nodedc-ui/calendar.css. Feature screens must consume NodeDcDateField or NodeDcCalendar directly instead of creating local date wrappers."
|
||||
},
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/core/components/dropdowns/date.tsx",
|
||||
"exports": ["DateDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-calendar-shell"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Calendar.tsx",
|
||||
"exports": [
|
||||
"NodeDcDateField",
|
||||
"NodeDcCalendar",
|
||||
"NodeDcDateFieldProps",
|
||||
"NodeDcCalendarProps",
|
||||
"NodeDcCalendarRangePreview",
|
||||
"NodeDcDateInput",
|
||||
"NodeDcWeekStartsOn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/calendar.css",
|
||||
"classes": ["nodedc-ui-calendar", "nodedc-ui-calendar-frame", "nodedc-ui-date-trigger"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"consumers": ["ClientsSection", "ClientEditorModal", "InvitesSection"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"date trigger button",
|
||||
"portal dropdown surface",
|
||||
"calendar shell",
|
||||
"month/year controls",
|
||||
"day buttons",
|
||||
"single or range mode"
|
||||
],
|
||||
"visualContract": {
|
||||
"surface": "DropdownSurface",
|
||||
"calendarRadius": "1.1rem",
|
||||
"dayButtonRadius": "999px",
|
||||
"selectedRangeFill": "continuous accent row fill under the day numbers; row breaks create rounded start/end caps",
|
||||
"todayMarker": "today is always a circular ring marker; inside an accent range the ring uses the dark on-accent color and the day cell remains range-filled, not solid-filled",
|
||||
"outsideDays": "muted"
|
||||
},
|
||||
"behaviorContract": [
|
||||
"Default placement bottom-start.",
|
||||
"Render through portal.",
|
||||
"Support minDate, maxDate, clearable value, closeOnSelect, empty labels, popover width, and start-of-week preference.",
|
||||
"Close on select when closeOnSelect is true.",
|
||||
"When rangePreview or rangeStart/rangeEnd are present, render a continuous accent range preview while still selecting one field value.",
|
||||
"When only one date is present, render a single circular selected date.",
|
||||
"Today must not be solid-filled. It uses an inset circular ring both outside and inside a range; inside a range the row fill remains visible behind the ring."
|
||||
],
|
||||
"currentConsumers": [
|
||||
"Client demo/contract/paid dates",
|
||||
"Invite expiration dates",
|
||||
"Any future date field in admin modals and tables"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"id": "circle-action-button",
|
||||
"name": "CircleActionButton",
|
||||
"kind": "primitive",
|
||||
"status": "draft-stable",
|
||||
"summary": "Icon-only circular action control for close, add, edit, refresh, search, copy, drag-adjacent actions, and toolbar icon actions.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/ui/Button.tsx",
|
||||
"exports": ["IconButton"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["admin-circle-action", "admin-panel-close", "services-admin-table__edit"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-external-icon-button", "nodedc-toolbar-icon-button"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"button element",
|
||||
"single centered icon",
|
||||
"aria-label/title",
|
||||
"optional solid active/primary variant"
|
||||
],
|
||||
"geometry": {
|
||||
"size": "tokens.size.controlRing",
|
||||
"shape": "circle",
|
||||
"insetToHostEdge": "tokens.size.controlInset when anchored in a rounded container"
|
||||
},
|
||||
"variants": {
|
||||
"ghost": {
|
||||
"background": "transparent",
|
||||
"border": "soft white alpha only if needed",
|
||||
"hover": "white alpha surface"
|
||||
},
|
||||
"solid": {
|
||||
"background": "rgba(247, 248, 244, 0.96)",
|
||||
"color": "onAccentRgb"
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
"Do not replace with text pill when action is clear by icon.",
|
||||
"Close button starts transparent with only soft circular boundary.",
|
||||
"Hover may add subtle surface fill.",
|
||||
"All icon-only admin actions share the same diameter."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"id": "dropdown-surface",
|
||||
"name": "DropdownSurface",
|
||||
"kind": "primitive",
|
||||
"status": "stable-reference",
|
||||
"summary": "Canonical dark floating surface for selection dropdowns, action dropdowns, filters, and profile menu popovers.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "HDROPDOWN-CANON.md"
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-dropdown-surface", "nodedc-dropdown-search", "nodedc-dropdown-option"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Dropdown.tsx",
|
||||
"exports": ["NodeDcDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["nodedc-ui-dropdown-surface", "nodedc-dropdown-surface", "nodedc-ui-option", "nodedc-ui-dropdown-search"]
|
||||
}
|
||||
],
|
||||
"visualContract": {
|
||||
"background": "linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01)) over rgba(8,8,11,0.9)",
|
||||
"radius": "1.25rem",
|
||||
"padding": "0.75rem",
|
||||
"blur": "44px",
|
||||
"shadow": "tokens.shadow.dropdown",
|
||||
"border": "0"
|
||||
},
|
||||
"behaviorContract": [
|
||||
"Render on fixed/portal layer when inside cards, sidebars, tables, sticky headers, or scroll containers.",
|
||||
"Close on outside pointer and Escape.",
|
||||
"Use Popper or equivalent fixed-position placement based on trigger bounds.",
|
||||
"Default selection placement is bottom-start.",
|
||||
"Default action placement for card quick actions is bottom-start or bottom-end depending on anchor edge."
|
||||
],
|
||||
"optionContract": {
|
||||
"radius": "0.9rem",
|
||||
"hover": "rgba(255,255,255,0.06)",
|
||||
"outline": "none"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"id": "entity-modal",
|
||||
"name": "EntityModal",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Dark glass edit/create modal used for services, clients, users, and groups.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"functions": ["ServiceContentModal", "ClientEditorModal", "UserEditorModal", "GroupEditorModal", "EntityModalHead", "EntityModalFoot"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["service-content-modal-layer", "service-content-modal", "service-content-field"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-glass-modal", "nodedc-modal-input", "nodedc-modal-editor", "nodedc-modal-primary-button"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"fixed overlay layer",
|
||||
"modal shell",
|
||||
"head with eyebrow/title/close circle",
|
||||
"scrollable responsive grid",
|
||||
"fields",
|
||||
"footer with cancel, optional delete, save"
|
||||
],
|
||||
"visualContract": {
|
||||
"shellWidth": "min(58rem, viewport - 2.8rem)",
|
||||
"radius": "modal",
|
||||
"background": "dark matte glass",
|
||||
"blur": "modal",
|
||||
"fieldRadius": "1rem to 1.25rem",
|
||||
"footerButtonHeight": "2.75rem"
|
||||
},
|
||||
"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."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"id": "glass-panel",
|
||||
"name": "GlassPanel",
|
||||
"kind": "surface",
|
||||
"status": "draft-stable",
|
||||
"summary": "Dark matte glass container used for panels, cards, modal shells, dropdown shells, and admin content blocks.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/ui/Glass.tsx"
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"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-work-item-property-button", "nodedc-settings-card", "nodedc-modal-field", "nodedc-dropdown-surface"]
|
||||
}
|
||||
],
|
||||
"tokens": {
|
||||
"radius": "card | modal | control depending on scale",
|
||||
"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, 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."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"id": "launcher-showcase-stage",
|
||||
"name": "LauncherShowcaseStage",
|
||||
"kind": "recipe-component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Large showcase stage with looping ambient media, selected service media square, and glass description square.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/service-stage/ServiceStage.tsx"
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["service-stage", "stage-service-overlay", "stage-media-card", "stage-description-card", "stage-video-controls"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"stage container",
|
||||
"ambient background media",
|
||||
"topline back/title",
|
||||
"left side controls",
|
||||
"two centered equal squares: media and description",
|
||||
"bottom center previous/next controls"
|
||||
],
|
||||
"rules": [
|
||||
"The media card and description card have equal width and height.",
|
||||
"Cards are separated by a small gap and each has four rounded corners.",
|
||||
"Ambient media fills the stage and clips to radius.card.",
|
||||
"When admin panel content is open, stage content controls are hidden and media becomes decorative."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"id": "media-source-field",
|
||||
"name": "MediaSourceField",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Media input field that switches between local storage upload and external URL for card and background content.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"functions": ["MediaSourceField", "MediaPreview"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["service-media-control", "service-media-file-button", "service-media-source-button"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"label with icon",
|
||||
"rounded input/control shell",
|
||||
"file chooser button",
|
||||
"selected filename",
|
||||
"URL input",
|
||||
"two circular source buttons: file, url",
|
||||
"preview surface"
|
||||
],
|
||||
"behaviorContract": [
|
||||
"Local file upload persists to public storage and returns stable URL.",
|
||||
"Source switch decides whether field uses stored file URL or external URL.",
|
||||
"Accepts images, gifs, videos, and common video extensions.",
|
||||
"Preview renders video when mediaKind or extension indicates video."
|
||||
],
|
||||
"rules": [
|
||||
"The file chooser is a single rounded button, never browser default file input UI.",
|
||||
"The source buttons are circular and attached to the field right side.",
|
||||
"Background content and card content both accept the same media categories."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"id": "profile-menu",
|
||||
"name": "ProfileMenu",
|
||||
"kind": "component",
|
||||
"status": "stable-reference",
|
||||
"summary": "User profile popover opened from the top toolbar avatar. Uses ActionDropdown shell with custom menu content.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx",
|
||||
"exports": ["UserMenuRoot"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"exports": ["ActionDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/ProfileMenu.tsx",
|
||||
"exports": ["NodeDcProfileMenu"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/top-bar/TopBar.tsx",
|
||||
"functions": ["TopBar profile trigger"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"toolbar avatar trigger",
|
||||
"cover image header",
|
||||
"centered circular avatar",
|
||||
"user name and email",
|
||||
"action rows: settings, sign out",
|
||||
"optional admin-only action"
|
||||
],
|
||||
"visualContract": {
|
||||
"menuWidth": "18rem",
|
||||
"menuPadding": "0.75rem",
|
||||
"coverHeight": "7.25rem",
|
||||
"coverRadius": "0.5rem in task manager reference; can be upgraded to token radius.control",
|
||||
"rowRadius": "0.9rem",
|
||||
"rowHover": "rgba(255,255,255,0.06)"
|
||||
},
|
||||
"rules": [
|
||||
"Profile popover is an ActionDropdown menuContent variant, not a separate dropdown engine.",
|
||||
"Avatar trigger stays circular and borderless.",
|
||||
"Menu uses DropdownSurface/ActionDropdown stacking rules.",
|
||||
"Launcher implementation must keep the profile trigger pill visually identical to Task Manager header."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"id": "segmented-nav",
|
||||
"name": "SegmentedNav",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Rounded grouped navigation control used in topbars and mode switchers.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["nodedc-expanded-nav-group", "nodedc-expanded-nav-button"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-toolbar-group", "nodedc-toolbar-pill", "nodedc-toolbar-primary"]
|
||||
}
|
||||
],
|
||||
"visualContract": {
|
||||
"groupRadius": "999px",
|
||||
"groupPadding": "0.25rem",
|
||||
"buttonRadius": "999px",
|
||||
"buttonHeight": "2.5rem to 3rem depending on header scale",
|
||||
"activeFill": "white or accent depending on context",
|
||||
"inactiveFill": "transparent or dark group surface"
|
||||
},
|
||||
"rules": [
|
||||
"No outlines.",
|
||||
"All items share height.",
|
||||
"Active segment does not resize the group.",
|
||||
"Use text labels only for route/mode tabs; icon-only actions use CircleActionButton."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"id": "select-dropdown",
|
||||
"name": "NodeDcSelect",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Shared select/dropdown control for statuses, roles, clients, profile switches, filters, and compact table cells.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Select.tsx",
|
||||
"exports": ["NodeDcSelect", "NodeDcSelectOption"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Dropdown.tsx",
|
||||
"exports": ["NodeDcDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"exports": ["ActionDropdown"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"trigger button or custom trigger render",
|
||||
"portal DropdownSurface",
|
||||
"optional search row",
|
||||
"option rows with optional icon, description, tone, and selected check"
|
||||
],
|
||||
"visualContract": {
|
||||
"triggerRadius": "999px for table/header/status controls, 1rem inside entity forms",
|
||||
"menuSurface": "DropdownSurface",
|
||||
"optionRadius": "0.92rem",
|
||||
"selectedFill": "white/10 or tone-specific transparent fill",
|
||||
"outline": "none"
|
||||
},
|
||||
"behaviorContract": [
|
||||
"Never use native select for NODE.DC runtime UI.",
|
||||
"Render options through portal/fixed dropdown layer.",
|
||||
"Close on option select, outside pointer, and Escape.",
|
||||
"Support custom trigger render for header pills and side-nav client switcher."
|
||||
],
|
||||
"currentConsumers": [
|
||||
"TopBar client switcher",
|
||||
"TopBar profile-role switcher",
|
||||
"AdminOverlay client switcher",
|
||||
"AdminOverlay client type selects",
|
||||
"AdminOverlay membership role selects",
|
||||
"AdminOverlay invite role selects"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"id": "service-rail-card",
|
||||
"name": "ServiceRailCard",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Bottom launcher rail item made from a square media tile and separate glass description tile with an attached circular arrow action.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/service-rail/ServiceRail.tsx"
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["service-rail", "service-rail-card", "service-rail-card__media", "service-rail-card__body"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"rail glass strip with ambient media under blur",
|
||||
"service card group",
|
||||
"square media tile",
|
||||
"separate square body tile",
|
||||
"status pill",
|
||||
"circular arrow action"
|
||||
],
|
||||
"layoutContract": [
|
||||
"One service centers in the rail.",
|
||||
"Two services divide available rail into three equal gaps.",
|
||||
"Three services divide into five equal gaps.",
|
||||
"When gaps would become smaller than one tile size, horizontal scroll starts."
|
||||
],
|
||||
"rules": [
|
||||
"Media and body tiles are separated by a small seam gap.",
|
||||
"Both tiles have four rounded corners.",
|
||||
"Tiles touch rail vertical bounds from top to bottom after rail padding rules.",
|
||||
"The arrow action can select/open the service card."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"id": "status-control",
|
||||
"name": "StatusControl",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Status pill and status dropdown for service/client/user/invite/sync/audit states.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"functions": ["ServiceStatusDropdown", "AdminStatusDropdown", "AdminStatusPill"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["service-status-trigger", "admin-status-trigger", "admin-status-menu"]
|
||||
}
|
||||
],
|
||||
"geometry": {
|
||||
"width": "8.65rem for admin status, 7.45rem for compact service status",
|
||||
"height": "2.08rem",
|
||||
"radius": "999px",
|
||||
"labelAlignment": "center"
|
||||
},
|
||||
"tones": ["green", "yellow", "violet", "red", "muted"],
|
||||
"rules": [
|
||||
"No outline and no select browser arrow inside status pills.",
|
||||
"Clickable status opens DropdownSurface menu.",
|
||||
"Static status uses the same pill geometry but pointer-events none.",
|
||||
"Color opacity must stay restrained; status cannot become a bright visual hotspot."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"id": "work-item-card",
|
||||
"name": "WorkItemCard",
|
||||
"kind": "component",
|
||||
"status": "stable-reference",
|
||||
"summary": "Task Manager kanban work item card with avatar header, centered title, progress, footer metadata, and ellipsis action dropdown.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx",
|
||||
"exports": ["NodedcWorkItemCard", "NodedcWorkItemProgress", "getNodedcWorkItemCardAppearance"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-work-item-card"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"rounded card surface",
|
||||
"header",
|
||||
"optional subtitle",
|
||||
"center title area",
|
||||
"segmented progress",
|
||||
"footer"
|
||||
],
|
||||
"geometry": {
|
||||
"radius": "28px in reference",
|
||||
"padding": "1rem",
|
||||
"minContentHeight": "220px"
|
||||
},
|
||||
"states": {
|
||||
"passive": "cardPassive surface with muted foreground",
|
||||
"active": "cardActive accent surface with on-card-active foreground"
|
||||
},
|
||||
"rules": [
|
||||
"Kanban card ellipsis uses ActionDropdown.",
|
||||
"Card click must not intercept dropdown trigger clicks.",
|
||||
"Progress segments use three equal tracks and accent fill when passive."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"serverName": "nodedc-ui-guideline",
|
||||
"recommendedResources": [
|
||||
{
|
||||
"uri": "nodedc-ui://registry",
|
||||
"file": "registry.json",
|
||||
"description": "Top-level component registry."
|
||||
},
|
||||
{
|
||||
"uri": "nodedc-ui://tokens",
|
||||
"file": "tokens/nodedc.tokens.json",
|
||||
"description": "Machine-readable design tokens."
|
||||
},
|
||||
{
|
||||
"uri": "nodedc-ui://rules",
|
||||
"file": "rules/ui-rules.json",
|
||||
"description": "Global UI rules and forbidden patterns."
|
||||
},
|
||||
{
|
||||
"uri": "nodedc-ui://sources",
|
||||
"file": "sources/source-map.json",
|
||||
"description": "Source implementation map across launcher and task manager."
|
||||
}
|
||||
],
|
||||
"toolingNotes": [
|
||||
"Expose each components/*.json file as a read-only resource.",
|
||||
"Expose recipes/*.json as composition examples.",
|
||||
"When generating UI, load registry, tokens, rules, then the specific component specs.",
|
||||
"Prefer source references over re-inventing markup."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"id": "admin-catalog",
|
||||
"name": "AdminCatalogRecipe",
|
||||
"summary": "Composition for launcher admin panel and catalog-style admin sections.",
|
||||
"components": [
|
||||
"admin-side-nav",
|
||||
"admin-table",
|
||||
"status-control",
|
||||
"calendar-popover",
|
||||
"circle-action-button",
|
||||
"entity-modal",
|
||||
"media-source-field",
|
||||
"dropdown-surface",
|
||||
"action-dropdown"
|
||||
],
|
||||
"layout": {
|
||||
"base": "left AdminSideNav + optional AdminTable content + decorative compressed stage media",
|
||||
"navOnly": "stage squeezes but keeps showcase controls",
|
||||
"contentOpen": "content table opens next to nav; stage becomes narrow decorative media"
|
||||
},
|
||||
"compositionRules": [
|
||||
"Admin nav buttons use the same row geometry as project quick-select buttons.",
|
||||
"Table create actions are circular plus buttons.",
|
||||
"Inline status fields use StatusControl, not native select UI.",
|
||||
"Dates use CalendarPopover, with Task Manager calendar as reference.",
|
||||
"Edit opens EntityModal; content/media uses MediaSourceField.",
|
||||
"All dropdowns use portal/fixed layer."
|
||||
],
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"files": [
|
||||
"src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"src/styles/globals.css"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"files": [
|
||||
"plane-src/apps/web/core/components/dropdowns/date.tsx",
|
||||
"plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"plane-src/apps/web/styles/globals.css"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"id": "launcher-shell",
|
||||
"name": "LauncherShellRecipe",
|
||||
"summary": "Composition for NODE.DC launcher main screen.",
|
||||
"components": [
|
||||
"app-header",
|
||||
"segmented-nav",
|
||||
"launcher-showcase-stage",
|
||||
"service-rail-card",
|
||||
"circle-action-button",
|
||||
"status-control",
|
||||
"glass-panel"
|
||||
],
|
||||
"layout": {
|
||||
"root": "dark monocolor app background",
|
||||
"top": "NodeDcAppHeader",
|
||||
"center": "LauncherShowcaseStage",
|
||||
"bottom": "ServiceRailCard collection in rail strip"
|
||||
},
|
||||
"compositionRules": [
|
||||
"Header stays visually identical to Task Manager header where possible.",
|
||||
"Stage and rail respect pagePad and panelGap tokens.",
|
||||
"Selected service opens the stage detail; repeat click can close detail where the screen supports collapsed state.",
|
||||
"Rail uses ambient media under glass, clipped to the strip."
|
||||
],
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"files": [
|
||||
"src/app/LauncherApp.tsx",
|
||||
"src/widgets/top-bar/TopBar.tsx",
|
||||
"src/widgets/service-stage/ServiceStage.tsx",
|
||||
"src/widgets/service-rail/ServiceRail.tsx",
|
||||
"src/styles/globals.css"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"id": "task-manager-board",
|
||||
"name": "TaskManagerBoardRecipe",
|
||||
"summary": "Composition for Task Manager project board, including kanban cards, action dropdowns, profile menu, and calendar.",
|
||||
"components": [
|
||||
"app-header",
|
||||
"segmented-nav",
|
||||
"work-item-card",
|
||||
"action-dropdown",
|
||||
"dropdown-surface",
|
||||
"calendar-popover",
|
||||
"profile-menu",
|
||||
"circle-action-button"
|
||||
],
|
||||
"layout": {
|
||||
"top": "Task Manager header with active route segmented nav and profile cluster",
|
||||
"board": "kanban columns with WorkItemCard items",
|
||||
"floatingMenus": "ActionDropdown and selection dropdowns on portal layer",
|
||||
"date": "CalendarPopover for task start/target dates"
|
||||
},
|
||||
"compositionRules": [
|
||||
"Card ellipsis uses ActionDropdown.",
|
||||
"Date chips open CalendarPopover.",
|
||||
"Profile avatar opens ProfileMenu through ActionDropdown menuContent.",
|
||||
"All popovers use DropdownSurface and never render inline inside cards."
|
||||
],
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"files": [
|
||||
"plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx",
|
||||
"plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx",
|
||||
"plane-src/apps/web/core/components/dropdowns/date.tsx",
|
||||
"plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"plane-src/apps/web/styles/globals.css"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"name": "NODE.DC UI Guideline",
|
||||
"purpose": "Component registry for NODE.DC launcher, task manager, future apps, and MCP-backed UI generation.",
|
||||
"sourceProjects": [
|
||||
{
|
||||
"id": "nodedc_launcher",
|
||||
"root": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher",
|
||||
"role": "current launcher implementation and first MCP guideline host"
|
||||
},
|
||||
{
|
||||
"id": "nodedc_taskmanager",
|
||||
"root": "/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER",
|
||||
"role": "reference implementation for app header, work item cards, action dropdowns, profile menu, and calendar"
|
||||
}
|
||||
],
|
||||
"tokens": {
|
||||
"json": "tokens/nodedc.tokens.json",
|
||||
"css": "tokens/nodedc.tokens.css"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"id": "app-header",
|
||||
"spec": "components/app-header.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "segmented-nav",
|
||||
"spec": "components/segmented-nav.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "circle-action-button",
|
||||
"spec": "components/circle-action-button.json",
|
||||
"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",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "select-dropdown",
|
||||
"spec": "components/select-dropdown.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "action-dropdown",
|
||||
"spec": "components/action-dropdown.json",
|
||||
"status": "stable-reference",
|
||||
"primarySource": "nodedc_taskmanager"
|
||||
},
|
||||
{
|
||||
"id": "status-control",
|
||||
"spec": "components/status-control.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "calendar-popover",
|
||||
"spec": "components/calendar-popover.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "profile-menu",
|
||||
"spec": "components/profile-menu.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "admin-side-nav",
|
||||
"spec": "components/admin-side-nav.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "admin-table",
|
||||
"spec": "components/admin-table.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "entity-modal",
|
||||
"spec": "components/entity-modal.json",
|
||||
"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",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "launcher-showcase-stage",
|
||||
"spec": "components/launcher-showcase-stage.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "service-rail-card",
|
||||
"spec": "components/service-rail-card.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher"
|
||||
},
|
||||
{
|
||||
"id": "work-item-card",
|
||||
"spec": "components/work-item-card.json",
|
||||
"status": "stable-reference",
|
||||
"primarySource": "nodedc_taskmanager"
|
||||
}
|
||||
],
|
||||
"recipes": [
|
||||
"recipes/launcher-shell.recipe.json",
|
||||
"recipes/admin-catalog.recipe.json",
|
||||
"recipes/task-manager-board.recipe.json"
|
||||
],
|
||||
"globalRules": "rules/ui-rules.json",
|
||||
"sourceMap": "sources/source-map.json"
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"rules": [
|
||||
{
|
||||
"id": "no-random-local-ui",
|
||||
"severity": "error",
|
||||
"text": "Do not create a local one-off UI element when a registry component exists."
|
||||
},
|
||||
{
|
||||
"id": "no-outlines",
|
||||
"severity": "error",
|
||||
"text": "Visible outlines, stroke borders, focus rings, and hard colored contours are forbidden on runtime UI. Use only fill, opacity, blur, and surface hover/focus states."
|
||||
},
|
||||
{
|
||||
"id": "circle-actions",
|
||||
"severity": "error",
|
||||
"text": "Icon-only actions are circular controls. They use the shared control-ring size and 5px inset rules."
|
||||
},
|
||||
{
|
||||
"id": "dropdowns-portal",
|
||||
"severity": "error",
|
||||
"text": "Dropdowns inside cards, tables, sidebars, sticky headers, and scroll containers render through a portal/fixed layer."
|
||||
},
|
||||
{
|
||||
"id": "dropdowns-shared-engine",
|
||||
"severity": "error",
|
||||
"text": "Selection dropdowns and action dropdowns use the shared dropdown engine. Do not implement local outside-click and absolute inline menus."
|
||||
},
|
||||
{
|
||||
"id": "matte-glass",
|
||||
"severity": "error",
|
||||
"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",
|
||||
"severity": "error",
|
||||
"text": "Statuses use the shared status-tone palette, centered label, no outline, and fixed status control geometry."
|
||||
},
|
||||
{
|
||||
"id": "admin-tables",
|
||||
"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",
|
||||
"text": "Calendar popovers must follow the Task Manager nodedc-calendar-shell and portal dropdown pattern."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"projects": {
|
||||
"nodedc_launcher": {
|
||||
"root": "/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher",
|
||||
"sources": {
|
||||
"tokensAndCss": [
|
||||
"src/styles/globals.css"
|
||||
],
|
||||
"colorUtilities": [
|
||||
"src/shared/lib/accentContrast.ts"
|
||||
],
|
||||
"topBar": [
|
||||
"src/widgets/top-bar/TopBar.tsx"
|
||||
],
|
||||
"launcherStage": [
|
||||
"src/widgets/service-stage/ServiceStage.tsx"
|
||||
],
|
||||
"serviceRail": [
|
||||
"src/widgets/service-rail/ServiceRail.tsx"
|
||||
],
|
||||
"adminOverlay": [
|
||||
"src/widgets/admin-overlay/AdminOverlay.tsx"
|
||||
],
|
||||
"primitiveUi": [
|
||||
"src/shared/ui/Button.tsx",
|
||||
"src/shared/ui/Glass.tsx",
|
||||
"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"
|
||||
],
|
||||
"storage": [
|
||||
"src/shared/api/storageApi.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"nodedc_taskmanager": {
|
||||
"root": "/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER",
|
||||
"sources": {
|
||||
"existingDocs": [
|
||||
"HDESIGN-CODE.md",
|
||||
"HDROPDOWN-CANON.md",
|
||||
"HUI-CANON-AUDIT.md",
|
||||
"design.config.json"
|
||||
],
|
||||
"tokensAndCss": [
|
||||
"plane-src/apps/web/styles/globals.css"
|
||||
],
|
||||
"actionDropdown": [
|
||||
"plane-src/packages/ui/src/dropdowns/action-dropdown.tsx"
|
||||
],
|
||||
"dateDropdown": [
|
||||
"plane-src/apps/web/core/components/dropdowns/date.tsx"
|
||||
],
|
||||
"profileMenu": [
|
||||
"plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx"
|
||||
],
|
||||
"workItemCard": [
|
||||
"plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx"
|
||||
],
|
||||
"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"],
|
||||
"taskManager": ["nodedc-dropdown-surface", "nodedc-dropdown-option"]
|
||||
},
|
||||
{
|
||||
"component": "select-dropdown",
|
||||
"launcher": ["NodeDcSelect", "TopBar selectors", "AdminOverlay role/type/client selectors"],
|
||||
"taskManager": ["ActionDropdown option menu pattern"]
|
||||
},
|
||||
{
|
||||
"component": "calendar-popover",
|
||||
"launcher": ["NodeDcDateField", "NodeDcCalendar", "src/shared/nodedc-ui/calendar.css"],
|
||||
"taskManager": ["DateDropdown", "nodedc-calendar-shell"]
|
||||
},
|
||||
{
|
||||
"component": "profile-menu",
|
||||
"launcher": ["NodeDcProfileMenu", "TopBar profile cluster"],
|
||||
"taskManager": ["UserMenuRoot"]
|
||||
},
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
:root {
|
||||
--nodedc-accent-rgb: 195 255 102;
|
||||
--nodedc-card-passive-rgb: 42 43 46;
|
||||
--nodedc-card-active-rgb: 195 255 102;
|
||||
--nodedc-on-accent-rgb: 11 17 23;
|
||||
--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;
|
||||
--nodedc-radius-option: 0.9rem;
|
||||
--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;
|
||||
--nodedc-status-height: 2.08rem;
|
||||
--nodedc-page-pad: 1.25rem;
|
||||
--nodedc-panel-gap: 1.25rem;
|
||||
--nodedc-panel-pad: 1rem;
|
||||
--nodedc-dropdown-blur: 44px;
|
||||
--nodedc-panel-blur: 28px;
|
||||
--nodedc-modal-blur: 34px;
|
||||
--nodedc-detail-blur: 44px;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"name": "NODE.DC Design Tokens",
|
||||
"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)",
|
||||
"surfacePanel": "rgba(10, 10, 13, 0.88)",
|
||||
"surfaceControl": "rgba(255, 255, 255, 0.06)",
|
||||
"surfaceControlHover": "rgba(255, 255, 255, 0.10)",
|
||||
"textPrimary": "rgba(255, 255, 255, 0.94)",
|
||||
"textSecondary": "rgba(255, 255, 255, 0.74)",
|
||||
"textMuted": "rgba(255, 255, 255, 0.54)"
|
||||
},
|
||||
"radius": {
|
||||
"modal": "1.75rem",
|
||||
"card": "1.35rem",
|
||||
"control": "1.25rem",
|
||||
"dropdownOption": "0.9rem",
|
||||
"calendar": "1.1rem",
|
||||
"circle": "999px"
|
||||
},
|
||||
"size": {
|
||||
"toolbarHeight": "3rem",
|
||||
"toolbarPillHeight": "2.5rem",
|
||||
"controlRing": "2.92rem",
|
||||
"controlInset": "5px",
|
||||
"statusWidth": "8.65rem",
|
||||
"statusHeight": "2.08rem",
|
||||
"modalButtonHeight": "2.75rem",
|
||||
"launcherRailHeight": "9.2rem"
|
||||
},
|
||||
"space": {
|
||||
"pagePad": "1.25rem",
|
||||
"panelGap": "1.25rem",
|
||||
"panelPad": "1rem",
|
||||
"controlGap": "0.65rem",
|
||||
"dropdownPad": "0.75rem",
|
||||
"tableCellX": "0.75rem",
|
||||
"tableCellY": "0.48rem"
|
||||
},
|
||||
"blur": {
|
||||
"dropdown": "44px",
|
||||
"panel": "28px",
|
||||
"modal": "34px",
|
||||
"detail": "44px",
|
||||
"control": "18px"
|
||||
},
|
||||
"shadow": {
|
||||
"dropdown": "0 22px 60px rgba(0, 0, 0, 0.36), 0 6px 18px rgba(0, 0, 0, 0.2)",
|
||||
"panel": "0 34px 110px rgba(0, 0, 0, 0.52)",
|
||||
"modal": "0 34px 120px rgba(0, 0, 0, 0.62)",
|
||||
"workItem": "0 14px 34px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.035)"
|
||||
},
|
||||
"statusTone": {
|
||||
"green": {
|
||||
"background": "rgba(181, 255, 90, 0.075)",
|
||||
"color": "rgba(226, 255, 190, 0.94)"
|
||||
},
|
||||
"yellow": {
|
||||
"background": "rgba(246, 201, 95, 0.075)",
|
||||
"color": "rgba(255, 232, 178, 0.94)"
|
||||
},
|
||||
"violet": {
|
||||
"background": "rgba(210, 197, 255, 0.07)",
|
||||
"color": "rgba(232, 225, 255, 0.92)"
|
||||
},
|
||||
"red": {
|
||||
"background": "rgba(255, 120, 120, 0.07)",
|
||||
"color": "rgba(255, 216, 216, 0.92)"
|
||||
},
|
||||
"muted": {
|
||||
"background": "rgba(255, 255, 255, 0.075)",
|
||||
"color": "rgba(255, 255, 255, 0.78)"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
design.md
|
|
@ -118,7 +118,26 @@ Popup, admin overlay, rail-карточки, формы и таблицы исп
|
|||
|
||||
Все dropdown, которые открываются внутри карточек, таблиц, scroll-контейнеров, sticky header или detail panel, должны рендериться через portal. Inline popup внутри ограниченного контейнера считается дефектом, потому что создаёт clipping и визуальное налезание.
|
||||
|
||||
Для текущего MVP, где dropdown можно заменить компактным selector/segmented control без clipping-риска, portal можно не вводить насильно. Но reusable `PortalDropdown` остаётся обязательной точкой расширения.
|
||||
Launcher использует тот же dropdown-канон, что и Task Manager:
|
||||
|
||||
- shell: `portal-dropdown` / `nodedc-dropdown-surface`;
|
||||
- option row: `nodedc-dropdown-option`;
|
||||
- placement по умолчанию `bottom-start`;
|
||||
- вертикальный offset небольшой, боковой offset без ручной подгонки;
|
||||
- surface тёмный matte glass с blur, без светлой подложки и без технического outline;
|
||||
- active/selected state нейтральный, без разноцветных status-fill;
|
||||
- если trigger уже показывает значение, стрелка внутри trigger не обязательна и не должна ломать ширину control.
|
||||
|
||||
Статусные selector-ы в таблицах:
|
||||
|
||||
- не используют нативный browser `select`;
|
||||
- не красят каждый статус разным цветом;
|
||||
- имеют одну нейтральную pill-плашку;
|
||||
- текст внутри pill всегда центрируется;
|
||||
- dropdown открывается по клику на всю pill-плашку;
|
||||
- popup рендерится через `PortalDropdown`, а не inline внутри таблицы.
|
||||
|
||||
Для текущего MVP reusable `PortalDropdown` остаётся обязательной точкой расширения. Если появляется новый popup/selector, сначала расширяется shared-компонент и только потом применяется на экране.
|
||||
|
||||
## 9. Пользовательская витрина
|
||||
|
||||
|
|
|
|||
12
package.json
|
|
@ -4,14 +4,22 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev": "node server/dev-server.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"dev:vite": "vite --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^5.2.1",
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.5.0",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
|
@ -0,0 +1,236 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const publicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
||||
const distDataPath = join(projectRoot, "dist", "storage", "launcher-data.json");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existingData = readJson(publicDataPath);
|
||||
const services = Array.isArray(existingData.services) ? existingData.services : [];
|
||||
const existingUsersByEmail = new Map(
|
||||
(Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user])
|
||||
);
|
||||
const dcTouchAuthentikUserId = existingUsersByEmail.get("dcctouch@gmail.com")?.authentikUserId ?? null;
|
||||
const silverPsihAuthentikUserId = existingUsersByEmail.get("silver_psih@yahoo.com")?.authentikUserId ?? null;
|
||||
|
||||
const liveData = {
|
||||
...existingData,
|
||||
clients: [
|
||||
{
|
||||
id: "client_romashka",
|
||||
type: "company",
|
||||
name: "DCTOUCH",
|
||||
legalName: "ООО ДИСИТАЧ",
|
||||
status: "active",
|
||||
contractStartsAt: "2026-05-04T00:00:00.000Z",
|
||||
contractEndsAt: null,
|
||||
paidUntil: null,
|
||||
demoEndsAt: null,
|
||||
contactName: "DC Touch",
|
||||
contactEmail: "dcctouch@gmail.com",
|
||||
notes: "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: "user_root",
|
||||
authentikUserId: dcTouchAuthentikUserId,
|
||||
name: "DC Touch",
|
||||
email: "dcctouch@gmail.com",
|
||||
phone: null,
|
||||
position: "NODE.DC Super Admin",
|
||||
notes: "Главный супер-администратор NODE.DC. Authentik-пользователь уже создан в dev-контуре.",
|
||||
avatarUrl: null,
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_silver_psih",
|
||||
authentikUserId: silverPsihAuthentikUserId,
|
||||
name: "Silver Psy",
|
||||
email: "silver_psih@yahoo.com",
|
||||
phone: null,
|
||||
position: "Manager",
|
||||
notes: "Живой пользователь из Plane. Требует создания/синхронизации в Authentik через Launcher flow.",
|
||||
avatarUrl: null,
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
memberships: [
|
||||
{
|
||||
id: "mem_dc_touch_dctouch",
|
||||
clientId: "client_romashka",
|
||||
userId: "user_root",
|
||||
role: "client_owner",
|
||||
status: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "mem_silver_psih_dctouch",
|
||||
clientId: "client_romashka",
|
||||
userId: "user_silver_psih",
|
||||
role: "member",
|
||||
status: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: "group_dctouch_admins",
|
||||
clientId: "client_romashka",
|
||||
name: "Администраторы",
|
||||
description: "Администраторы клиента и владельцы платформенного доступа.",
|
||||
memberIds: ["user_root"],
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "group_dctouch_managers",
|
||||
clientId: "client_romashka",
|
||||
name: "Менеджеры",
|
||||
description: "Рабочая группа менеджеров с доступом к операционному контуру.",
|
||||
memberIds: ["user_silver_psih"],
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
grants: [
|
||||
{
|
||||
id: "grant_dctouch_task_admins",
|
||||
serviceId: "service_task_manager",
|
||||
targetType: "group",
|
||||
targetId: "group_dctouch_admins",
|
||||
appRole: "admin",
|
||||
status: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "grant_dctouch_task_managers",
|
||||
serviceId: "service_task_manager",
|
||||
targetType: "group",
|
||||
targetId: "group_dctouch_managers",
|
||||
appRole: "member",
|
||||
status: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "grant_dctouch_nodedc_admins",
|
||||
serviceId: "service_nodedc",
|
||||
targetType: "group",
|
||||
targetId: "group_dctouch_admins",
|
||||
appRole: "admin",
|
||||
status: "active",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
exceptions: [],
|
||||
invites: [],
|
||||
syncStatuses: [
|
||||
{
|
||||
id: "sync_dctouch_client_authentik",
|
||||
objectId: "client_romashka",
|
||||
objectName: "DCTOUCH",
|
||||
objectType: "client",
|
||||
target: "authentik",
|
||||
state: "synced",
|
||||
lastSyncAt: now,
|
||||
error: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "sync_dc_touch_authentik",
|
||||
objectId: "user_root",
|
||||
objectName: "dcctouch@gmail.com",
|
||||
objectType: "user",
|
||||
target: "authentik",
|
||||
state: dcTouchAuthentikUserId ? "synced" : "pending",
|
||||
lastSyncAt: dcTouchAuthentikUserId ? now : null,
|
||||
error: dcTouchAuthentikUserId ? null : "Пользователь есть в Authentik, но Launcher seed ещё не содержит Authentik UUID.",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "sync_silver_psih_authentik",
|
||||
objectId: "user_silver_psih",
|
||||
objectName: "silver_psih@yahoo.com",
|
||||
objectType: "user",
|
||||
target: "authentik",
|
||||
state: silverPsihAuthentikUserId ? "synced" : "pending",
|
||||
lastSyncAt: silverPsihAuthentikUserId ? now : null,
|
||||
error: silverPsihAuthentikUserId
|
||||
? null
|
||||
: "Пользователь найден в Plane, но ещё не создан в Authentik через Launcher invite/sync flow.",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "sync_dctouch_groups_authentik",
|
||||
objectId: "client_romashka:groups",
|
||||
objectName: "DCTOUCH groups",
|
||||
objectType: "group",
|
||||
target: "authentik",
|
||||
state: "pending",
|
||||
lastSyncAt: null,
|
||||
error: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "sync_task_manager_authentik",
|
||||
objectId: "service_task_manager",
|
||||
objectName: "OPERATIONAL CORE",
|
||||
objectType: "service",
|
||||
target: "authentik",
|
||||
state: "synced",
|
||||
lastSyncAt: now,
|
||||
error: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
auditEvents: [
|
||||
{
|
||||
id: "audit_live_seed_control_plane",
|
||||
at: now,
|
||||
actorUserId: "system",
|
||||
actorName: "NODE.DC seed",
|
||||
action: "Применён live seed control-plane",
|
||||
objectType: "control_plane",
|
||||
objectName: "Launcher users and access",
|
||||
clientId: "client_romashka",
|
||||
result: "success",
|
||||
details: "Demo-участники удалены из runtime storage. Оставлены dcctouch@gmail.com и silver_psih@yahoo.com.",
|
||||
},
|
||||
],
|
||||
services,
|
||||
};
|
||||
|
||||
await writeJson(publicDataPath, liveData);
|
||||
|
||||
if (existsSync(join(projectRoot, "dist"))) {
|
||||
await writeJson(distDataPath, liveData);
|
||||
}
|
||||
|
||||
console.log(`Seeded ${liveData.users.length} users, ${liveData.clients.length} client, ${liveData.groups.length} groups.`);
|
||||
|
||||
function readJson(path) {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
async function writeJson(path, data) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const platformGroups = {
|
||||
superadmin: "nodedc:superadmin",
|
||||
launcherAdmin: "nodedc:launcher:admin",
|
||||
launcherUser: "nodedc:launcher:user",
|
||||
taskManagerAdmin: "nodedc:taskmanager:admin",
|
||||
taskManagerUser: "nodedc:taskmanager:user",
|
||||
};
|
||||
|
||||
export function createAuthentikSyncClient({ baseUrl, token }) {
|
||||
const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, "");
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(normalizedBaseUrl && token);
|
||||
}
|
||||
|
||||
async function provisionUser({ data, userId, password, generatePassword = false }) {
|
||||
ensureConfigured();
|
||||
|
||||
const user = findById(data.users, userId, "user");
|
||||
const requiredGroups = resolveRequiredGroups(data, user);
|
||||
const groups = await ensureGroups(requiredGroups);
|
||||
const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email);
|
||||
const temporaryPassword = password || (generatePassword && !existingUser ? generatePasswordValue() : null);
|
||||
const payload = {
|
||||
username: user.email.toLowerCase(),
|
||||
email: user.email.toLowerCase(),
|
||||
name: user.name,
|
||||
is_active: user.globalStatus === "active",
|
||||
type: "internal",
|
||||
groups: groups.map((group) => group.pk),
|
||||
attributes: {
|
||||
nodedc_user_id: user.id,
|
||||
nodedc_source: "launcher-control-plane",
|
||||
picture: user.avatarUrl || undefined,
|
||||
avatar_url: user.avatarUrl || undefined,
|
||||
},
|
||||
};
|
||||
const authentikUser = existingUser
|
||||
? await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: await requestJson("/api/v3/core/users/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (temporaryPassword) {
|
||||
await setPassword(authentikUser.pk, temporaryPassword);
|
||||
}
|
||||
|
||||
return {
|
||||
authentikUserId: String(authentikUser.uuid || authentikUser.uid || authentikUser.pk),
|
||||
authentikPk: authentikUser.pk,
|
||||
email: authentikUser.email,
|
||||
name: authentikUser.name,
|
||||
groups: requiredGroups,
|
||||
created: !existingUser,
|
||||
temporaryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
async function findUserByIdOrEmail(authentikUserId, email) {
|
||||
if (authentikUserId) {
|
||||
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
|
||||
const users = Array.isArray(payload.results) ? payload.results : [];
|
||||
const existingUser = users.find((user) => {
|
||||
const identifiers = [user.uuid, user.uid, user.pk].map((value) => String(value || ""));
|
||||
return identifiers.includes(String(authentikUserId));
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return existingUser;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(email)}`);
|
||||
const users = Array.isArray(payload.results) ? payload.results : [];
|
||||
return users.find((user) => String(user.email || "").toLowerCase() === email.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
async function ensureGroups(groupNames) {
|
||||
const groups = [];
|
||||
|
||||
for (const groupName of groupNames) {
|
||||
groups.push(await ensureGroup(groupName));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function ensureGroup(groupName) {
|
||||
const payload = await requestJson(`/api/v3/core/groups/?search=${encodeURIComponent(groupName)}`);
|
||||
const groups = Array.isArray(payload.results) ? payload.results : [];
|
||||
const existingGroup = groups.find((group) => group.name === groupName);
|
||||
|
||||
if (existingGroup) {
|
||||
return existingGroup;
|
||||
}
|
||||
|
||||
return requestJson("/api/v3/core/groups/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: groupName,
|
||||
is_superuser: false,
|
||||
attributes: {
|
||||
nodedc_source: "launcher-control-plane",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function setPassword(userPk, password) {
|
||||
await requestJson(`/api/v3/core/users/${encodeURIComponent(userPk)}/set_password/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson(path, init = {}) {
|
||||
ensureConfigured();
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Authentik API ${path} failed: HTTP ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
function ensureConfigured() {
|
||||
if (!isConfigured()) {
|
||||
throw new Error("Authentik API is not configured. Set AUTHENTIK_BOOTSTRAP_TOKEN or NODEDC_AUTHENTIK_SERVICE_TOKEN server-side.");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isConfigured,
|
||||
provisionUser,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRequiredGroups(data, user) {
|
||||
const groupNames = new Set();
|
||||
|
||||
if (user.globalStatus !== "active") {
|
||||
return [];
|
||||
}
|
||||
|
||||
groupNames.add(platformGroups.launcherUser);
|
||||
|
||||
if (user.id === "user_root") {
|
||||
groupNames.add(platformGroups.superadmin);
|
||||
groupNames.add(platformGroups.launcherAdmin);
|
||||
groupNames.add(platformGroups.taskManagerAdmin);
|
||||
groupNames.add(platformGroups.taskManagerUser);
|
||||
return [...groupNames];
|
||||
}
|
||||
|
||||
for (const client of data.clients) {
|
||||
const membership = getRuntimeMembership(data, user.id, client.id);
|
||||
|
||||
if (membership.status !== "active") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userGroups = getUserGroups(data, user.id, client.id);
|
||||
|
||||
for (const service of data.services) {
|
||||
const access = computeEffectiveAccess(data, { client, user, membership, userGroups, service });
|
||||
|
||||
if (!access.allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (service.slug === "task-manager") {
|
||||
groupNames.add(platformGroups.taskManagerUser);
|
||||
|
||||
if (access.appRole === "admin" || access.appRole === "owner") {
|
||||
groupNames.add(platformGroups.taskManagerAdmin);
|
||||
}
|
||||
} else if (service.authentikGroupName) {
|
||||
groupNames.add(service.authentikGroupName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...groupNames];
|
||||
}
|
||||
|
||||
function generatePasswordValue() {
|
||||
return `NDC-${randomBytes(15).toString("base64url")}`;
|
||||
}
|
||||
|
||||
function computeEffectiveAccess(data, { client, user, membership, userGroups, service }) {
|
||||
if (client.status === "suspended" || client.status === "expired") {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
if (user.globalStatus === "blocked" || membership.status === "disabled") {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
if (service.status === "disabled" || service.status === "hidden") {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const deny = data.exceptions.find(
|
||||
(exception) => exception.serviceId === service.id && exception.userId === user.id && exception.type === "deny"
|
||||
);
|
||||
|
||||
if (deny) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const allow = data.exceptions.find(
|
||||
(exception) => exception.serviceId === service.id && exception.userId === user.id && exception.type === "allow"
|
||||
);
|
||||
|
||||
if (allow) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const userGrant = data.grants.find(
|
||||
(grant) =>
|
||||
grant.serviceId === service.id &&
|
||||
grant.targetType === "user" &&
|
||||
grant.targetId === user.id &&
|
||||
grant.status === "active"
|
||||
);
|
||||
|
||||
if (userGrant) {
|
||||
return { allowed: true, appRole: userGrant.appRole };
|
||||
}
|
||||
|
||||
const groupIds = userGroups.map((group) => group.id);
|
||||
const groupGrant = data.grants.find(
|
||||
(grant) =>
|
||||
grant.serviceId === service.id &&
|
||||
grant.targetType === "group" &&
|
||||
groupIds.includes(grant.targetId) &&
|
||||
grant.status === "active"
|
||||
);
|
||||
|
||||
if (groupGrant) {
|
||||
return { allowed: true, appRole: groupGrant.appRole };
|
||||
}
|
||||
|
||||
const clientGrant = data.grants.find(
|
||||
(grant) =>
|
||||
grant.serviceId === service.id &&
|
||||
grant.targetType === "client" &&
|
||||
grant.targetId === client.id &&
|
||||
grant.status === "active"
|
||||
);
|
||||
|
||||
if (clientGrant) {
|
||||
return { allowed: true, appRole: clientGrant.appRole };
|
||||
}
|
||||
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
function getRuntimeMembership(data, userId, clientId) {
|
||||
return (
|
||||
data.memberships.find((membership) => membership.userId === userId && membership.clientId === clientId) ?? {
|
||||
id: `missing_${clientId}_${userId}`,
|
||||
clientId,
|
||||
userId,
|
||||
role: "member",
|
||||
status: "disabled",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getUserGroups(data, userId, clientId) {
|
||||
return data.groups.filter((group) => group.clientId === clientId && group.memberIds.includes(userId));
|
||||
}
|
||||
|
||||
function findById(items, id, label) {
|
||||
const item = items.find((candidate) => candidate.id === id);
|
||||
|
||||
if (!item) {
|
||||
throw new Error(`Unknown ${label}: ${id}`);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
|
@ -1,8 +1,32 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ServiceAccessException, ServiceGrant } from "../entities/access/types";
|
||||
import type { Client } from "../entities/client/types";
|
||||
import type { Invite } from "../entities/invite/types";
|
||||
import { syncServiceLaunchLink } from "../entities/service/links";
|
||||
import type { LauncherServiceView, Service } from "../entities/service/types";
|
||||
import type { SyncStatus } from "../entities/sync/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types";
|
||||
import {
|
||||
createAdminClient,
|
||||
createAdminGroup,
|
||||
createAdminInvite,
|
||||
createAdminService,
|
||||
createAdminUser,
|
||||
deleteAdminClient,
|
||||
deleteAdminGroup,
|
||||
deleteAdminInvite,
|
||||
deleteAdminMembership,
|
||||
deleteAdminService,
|
||||
fetchControlPlaneSnapshot,
|
||||
reorderAdminServices,
|
||||
retryAdminSync,
|
||||
setAdminUserServiceAccess,
|
||||
updateAdminClient,
|
||||
updateAdminGroup,
|
||||
updateAdminInvite,
|
||||
updateAdminMembership,
|
||||
updateAdminService,
|
||||
updateAdminUserProfile,
|
||||
type ControlPlaneMutationResult,
|
||||
} from "../shared/api/adminApi";
|
||||
import {
|
||||
buildLauncherServices,
|
||||
buildMe,
|
||||
|
|
@ -10,25 +34,109 @@ import {
|
|||
profileOptions,
|
||||
type LauncherData,
|
||||
} from "../shared/api/mockApi";
|
||||
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
|
||||
import { AdminOverlay } from "../widgets/admin-overlay/AdminOverlay";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
fetchAvailableApps,
|
||||
type AuthenticatedSession,
|
||||
type AuthSession,
|
||||
type LauncherAuthApp,
|
||||
} from "../shared/api/authApi";
|
||||
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||
import {
|
||||
AdminOverlay,
|
||||
type AccessAssignmentValue,
|
||||
type CreateUserCommand,
|
||||
type SetUserServiceAccessCommand,
|
||||
} from "../widgets/admin-overlay/AdminOverlay";
|
||||
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
|
||||
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||
import { TopBar } from "../widgets/top-bar/TopBar";
|
||||
|
||||
export function LauncherApp() {
|
||||
const [data, setData] = useState<LauncherData>(initialLauncherData);
|
||||
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
||||
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
||||
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
|
||||
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
const [storageHydrated, setStorageHydrated] = useState(false);
|
||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
||||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||
|
||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
|
||||
const runtimeMe = useMemo(() => {
|
||||
if (!authSession?.authenticated) return me;
|
||||
|
||||
return {
|
||||
...me,
|
||||
user: {
|
||||
...me.user,
|
||||
authentikUserId: authSession.user.sub,
|
||||
email: me.user.email || authSession.user.email,
|
||||
name: me.user.name || authSession.user.name,
|
||||
avatarUrl: me.user.avatarUrl ?? authSession.user.avatarUrl,
|
||||
},
|
||||
mockAuthentikClaims: {
|
||||
...me.mockAuthentikClaims,
|
||||
sub: authSession.user.sub,
|
||||
email: authSession.user.email || me.mockAuthentikClaims.email,
|
||||
name: authSession.user.name || me.mockAuthentikClaims.name,
|
||||
avatarUrl: authSession.user.avatarUrl ?? null,
|
||||
groups: authSession.groups,
|
||||
},
|
||||
};
|
||||
}, [authSession, me]);
|
||||
const resolvedClientId = me.activeClientId;
|
||||
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
||||
const launcherServices = useMemo(
|
||||
() => buildLauncherServices(data, activeProfileId, resolvedClientId),
|
||||
[data, activeProfileId, resolvedClientId]
|
||||
() => {
|
||||
const services = buildLauncherServices(data, activeProfileId, resolvedClientId);
|
||||
|
||||
if (!authSession?.authenticated || authApps === null) {
|
||||
return services;
|
||||
}
|
||||
|
||||
return services.map((service) => {
|
||||
const app = authAppsBySlug.get(service.slug);
|
||||
|
||||
if (!app) {
|
||||
return {
|
||||
...service,
|
||||
userAccess: "denied" as const,
|
||||
openUrl: null,
|
||||
effectiveAccess: {
|
||||
...service.effectiveAccess,
|
||||
allowed: false,
|
||||
visible: true,
|
||||
openEnabled: false,
|
||||
reason: "Нет доступа",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const openEnabled = app.hasAccess && app.status === "active";
|
||||
|
||||
return {
|
||||
...service,
|
||||
title: app.title || service.title,
|
||||
description: app.description || service.description,
|
||||
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
|
||||
userAccess: openEnabled ? ("allowed" as const) : ("denied" as const),
|
||||
effectiveAccess: {
|
||||
...service.effectiveAccess,
|
||||
allowed: app.hasAccess,
|
||||
visible: true,
|
||||
openEnabled,
|
||||
reason: app.accessReason || (app.hasAccess ? "Доступ подтверждён" : "Нет доступа"),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[authApps, authAppsBySlug, authSession, data, activeProfileId, resolvedClientId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -44,18 +152,61 @@ export function LauncherApp() {
|
|||
|
||||
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
fetchAuthSession()
|
||||
.then(async (session) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
setAuthSession(session);
|
||||
setAuthError(null);
|
||||
|
||||
if (!session.authenticated) {
|
||||
setAuthApps([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const apps = await fetchAvailableApps();
|
||||
|
||||
if (isMounted) {
|
||||
setAuthApps(apps);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
setAuthSession({ authenticated: false, loginUrl: "/auth/login" });
|
||||
setAuthApps([]);
|
||||
setAuthError(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
const nextContext = resolveAuthenticatedContext(data, authSession, activeProfileId, activeClientId);
|
||||
|
||||
if (activeProfileId !== nextContext.profileId) {
|
||||
setActiveProfileId(nextContext.profileId);
|
||||
}
|
||||
|
||||
if (activeClientId !== nextContext.clientId) {
|
||||
setActiveClientId(nextContext.clientId);
|
||||
}
|
||||
}, [activeClientId, activeProfileId, authSession, data]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
loadPersistedLauncherData()
|
||||
.then((persistedData) => {
|
||||
if (isMounted && persistedData) {
|
||||
setData(persistedData);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) {
|
||||
setStorageHydrated(true);
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -65,16 +216,78 @@ export function LauncherApp() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageHydrated) return;
|
||||
if (!authSession?.authenticated || !canUseAdminApi(authSession)) return;
|
||||
|
||||
const saveTimer = window.setTimeout(() => {
|
||||
persistLauncherData(data).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины");
|
||||
let isMounted = true;
|
||||
|
||||
fetchControlPlaneSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (isMounted) {
|
||||
setData(syncLauncherServiceLinks(snapshot.data));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось загрузить control-plane snapshot");
|
||||
});
|
||||
}, 350);
|
||||
|
||||
return () => window.clearTimeout(saveTimer);
|
||||
}, [data, storageHydrated]);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [authSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const refreshRuntimeState = async () => {
|
||||
try {
|
||||
const nextSession = await fetchAuthSession();
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setAuthSession(nextSession);
|
||||
setAuthError(null);
|
||||
|
||||
if (!nextSession.authenticated) {
|
||||
setAuthApps([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [persistedData, apps] = await Promise.all([
|
||||
canUseAdminApi(nextSession)
|
||||
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
||||
: loadPersistedLauncherData(),
|
||||
fetchAvailableApps(),
|
||||
]);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
|
||||
setAuthApps(apps);
|
||||
} catch (error: unknown) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||
}
|
||||
};
|
||||
|
||||
const eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("nodedc-runtime", () => {
|
||||
void refreshRuntimeState();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Launcher event stream disconnected; browser will retry automatically");
|
||||
};
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
eventSource.close();
|
||||
};
|
||||
}, [authSession?.authenticated]);
|
||||
|
||||
function handleProfileChange(userId: string) {
|
||||
const profile = profileOptions.find((option) => option.userId === userId);
|
||||
|
|
@ -92,150 +305,181 @@ export function LauncherApp() {
|
|||
setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId));
|
||||
}
|
||||
|
||||
function handleCreateGrant(grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
grants: [
|
||||
...current.grants,
|
||||
{
|
||||
...grant,
|
||||
id: `grant_mock_${Date.now()}`,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleStageStep(direction: "previous" | "next") {
|
||||
if (!launcherServices.length) return;
|
||||
|
||||
function handleCreateDenyException(exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
exceptions: [
|
||||
...current.exceptions.filter(
|
||||
(item) => !(item.serviceId === exception.serviceId && item.userId === exception.userId && item.type === "deny")
|
||||
),
|
||||
{
|
||||
...exception,
|
||||
id: `exception_mock_${Date.now()}`,
|
||||
type: "deny",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
setSelectedServiceId((current) => {
|
||||
const currentIndex = current ? launcherServices.findIndex((service) => service.id === current) : -1;
|
||||
const fallbackIndex = direction === "next" ? 0 : launcherServices.length - 1;
|
||||
const nextIndex =
|
||||
currentIndex === -1
|
||||
? fallbackIndex
|
||||
: direction === "next"
|
||||
? (currentIndex + 1) % launcherServices.length
|
||||
: (currentIndex - 1 + launcherServices.length) % launcherServices.length;
|
||||
|
||||
function handleRemoveException(exceptionId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
invites: [
|
||||
{
|
||||
...invite,
|
||||
id: `invite_mock_${Date.now()}`,
|
||||
invitedByUserId: me.user.id,
|
||||
token: `mock-${Date.now()}`,
|
||||
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "created",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
...current.invites,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRetrySync(syncId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
syncStatuses: current.syncStatuses.map((sync): SyncStatus =>
|
||||
sync.id === syncId
|
||||
? {
|
||||
...sync,
|
||||
state: "pending",
|
||||
error: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: sync
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
services: current.services.map((service) =>
|
||||
service.id === serviceId
|
||||
? {
|
||||
...service,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: service
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateService() {
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
setData((current) => {
|
||||
const nextOrder = Math.max(0, ...current.services.map((service) => service.order)) + 10;
|
||||
const id = `service_mock_${Date.now()}`;
|
||||
|
||||
return {
|
||||
...current,
|
||||
services: [
|
||||
...current.services,
|
||||
{
|
||||
id,
|
||||
slug: `new-service-${current.services.length + 1}`,
|
||||
title: "New Service",
|
||||
subtitle: "Новый сервис",
|
||||
description: "Описание сервиса для витрины.",
|
||||
fullDescription: "Заполните описание, медиа и ссылку запуска в редакторе контента.",
|
||||
url: "https://service.handhdc.ru",
|
||||
launchUrl: "https://service.handhdc.ru/sso/launch",
|
||||
accentColor: "#F7F8F4",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
|
||||
coverMediaSource: "url",
|
||||
coverMediaKind: "image",
|
||||
ambientMediaSource: "url",
|
||||
ambientMediaKind: "gif",
|
||||
status: "hidden",
|
||||
order: nextOrder,
|
||||
authentikApplicationSlug: `new-service-${current.services.length + 1}`,
|
||||
authentikGroupName: `service-new-${current.services.length + 1}`,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
return launcherServices[nextIndex]?.id;
|
||||
});
|
||||
}
|
||||
|
||||
function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>) {
|
||||
request
|
||||
.then((result) => {
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию");
|
||||
});
|
||||
}
|
||||
|
||||
function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) {
|
||||
const assignmentKey = accessAssignmentKey(userId, serviceId);
|
||||
|
||||
if (pendingAccessAssignments[assignmentKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAccessAssignments((current) => ({ ...current, [assignmentKey]: value }));
|
||||
setAdminUserServiceAccess({ userId, serviceId, value })
|
||||
.then((result) => {
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию");
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingAccessAssignments((current) => {
|
||||
const { [assignmentKey]: _completed, ...rest } = current;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||
applyControlPlaneMutation(createAdminInvite(invite));
|
||||
}
|
||||
|
||||
function handleUpdateInvite(inviteId: string, patch: Partial<Invite>) {
|
||||
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
|
||||
}
|
||||
|
||||
function handleDeleteInvite(inviteId: string) {
|
||||
applyControlPlaneMutation(deleteAdminInvite(inviteId));
|
||||
}
|
||||
|
||||
function handleRetrySync(syncId: string) {
|
||||
applyControlPlaneMutation(retryAdminSync(syncId));
|
||||
}
|
||||
|
||||
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||||
applyControlPlaneMutation(updateAdminService(serviceId, patch));
|
||||
}
|
||||
|
||||
function handleCreateClient() {
|
||||
const index = data.clients.length + 1;
|
||||
|
||||
applyControlPlaneMutation(
|
||||
createAdminClient({
|
||||
type: "company",
|
||||
name: `Новый клиент ${index}`,
|
||||
legalName: `Новый клиент ${index}`,
|
||||
status: "demo",
|
||||
demoEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
contactName: "",
|
||||
contactEmail: "",
|
||||
notes: "",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function handleUpdateClient(clientId: string, patch: Partial<Client>) {
|
||||
applyControlPlaneMutation(updateAdminClient(clientId, patch));
|
||||
}
|
||||
|
||||
function handleDeleteClient(clientId: string) {
|
||||
const nextClientId = data.clients.find((client) => client.id !== clientId)?.id ?? activeClientId;
|
||||
|
||||
applyControlPlaneMutation(deleteAdminClient(clientId));
|
||||
|
||||
if (activeClientId === clientId) {
|
||||
setActiveClientId(nextClientId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateUser(userId: string, patch: Partial<LauncherUser>) {
|
||||
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
|
||||
}
|
||||
|
||||
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
|
||||
const result = await updateOwnProfile(patch);
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
}
|
||||
|
||||
async function handleUpdateOwnPassword(newPassword: string) {
|
||||
const result = await updateOwnPassword(newPassword);
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
}
|
||||
|
||||
function handleCreateUser(command: CreateUserCommand) {
|
||||
createAdminUser(command)
|
||||
.then((result) => {
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
|
||||
if (result.provisioning?.temporaryPassword) {
|
||||
window.alert(`Пользователь создан. Временный пароль: ${result.provisioning.temporaryPassword}`);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось создать пользователя");
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateMembership(membershipId: string, patch: Partial<ClientMembership>) {
|
||||
applyControlPlaneMutation(updateAdminMembership(membershipId, patch));
|
||||
}
|
||||
|
||||
function handleDeleteMembership(membershipId: string) {
|
||||
applyControlPlaneMutation(deleteAdminMembership(membershipId));
|
||||
}
|
||||
|
||||
function handleCreateGroup(clientId: string) {
|
||||
applyControlPlaneMutation(createAdminGroup({ clientId, name: "Новая группа", description: "Описание группы", memberIds: [] }));
|
||||
}
|
||||
|
||||
function handleUpdateGroup(groupId: string, patch: Partial<ClientGroup>) {
|
||||
applyControlPlaneMutation(updateAdminGroup(groupId, patch));
|
||||
}
|
||||
|
||||
function handleDeleteGroup(groupId: string) {
|
||||
applyControlPlaneMutation(deleteAdminGroup(groupId));
|
||||
}
|
||||
|
||||
function handleReorderServices(orderedServiceIds: string[]) {
|
||||
applyControlPlaneMutation(reorderAdminServices(orderedServiceIds));
|
||||
}
|
||||
|
||||
function handleCreateService() {
|
||||
applyControlPlaneMutation(createAdminService());
|
||||
}
|
||||
|
||||
function handleDeleteService(serviceId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
services: current.services.filter((service) => service.id !== serviceId),
|
||||
grants: current.grants.filter((grant) => grant.serviceId !== serviceId),
|
||||
exceptions: current.exceptions.filter((exception) => exception.serviceId !== serviceId),
|
||||
}));
|
||||
applyControlPlaneMutation(deleteAdminService(serviceId));
|
||||
|
||||
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
|
||||
}
|
||||
|
||||
if (!authSession) {
|
||||
return <AuthStateScreen title="Проверяем сессию NODE.DC" description="Платформа подготавливает рабочую область и список приложений." />;
|
||||
}
|
||||
|
||||
if (!authSession.authenticated) {
|
||||
return <AuthStateScreen title="Вход на платформу NODE.DC" description="Войдите, чтобы открыть рабочую область и доступы." error={authError} loginUrl={authSession.loginUrl} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="launcher-app">
|
||||
<TopBar
|
||||
me={me}
|
||||
me={runtimeMe}
|
||||
clients={data.clients}
|
||||
profileOptions={profileOptions}
|
||||
activeProfileId={activeProfileId}
|
||||
|
|
@ -243,30 +487,171 @@ export function LauncherApp() {
|
|||
adminOpen={adminOpen}
|
||||
onProfileChange={handleProfileChange}
|
||||
onClientChange={setActiveClientId}
|
||||
onOpenAdmin={() => setAdminOpen(true)}
|
||||
onToggleAdmin={() => setAdminOpen((current) => !current)}
|
||||
onOpenShowcase={() => setAdminOpen(false)}
|
||||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||||
onLogout={() => window.location.assign(authSession.logoutUrl)}
|
||||
/>
|
||||
|
||||
<main className="launcher-main">
|
||||
<ServiceStage service={selectedService} hasServices={launcherServices.length > 0} onLaunch={handleLaunch} />
|
||||
<ServiceStage
|
||||
service={selectedService}
|
||||
hasServices={launcherServices.length > 0}
|
||||
onLaunch={handleLaunch}
|
||||
onSelectPrevious={() => handleStageStep("previous")}
|
||||
onSelectNext={() => handleStageStep("next")}
|
||||
/>
|
||||
{adminOpen && me.permissions.canOpenAdmin ? (
|
||||
<AdminOverlay
|
||||
data={data}
|
||||
me={me}
|
||||
me={runtimeMe}
|
||||
activeClientId={resolvedClientId}
|
||||
onClose={() => setAdminOpen(false)}
|
||||
onCreateGrant={handleCreateGrant}
|
||||
onCreateDenyException={handleCreateDenyException}
|
||||
onRemoveException={handleRemoveException}
|
||||
onSetUserServiceAccess={handleSetUserServiceAccess}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onUpdateInvite={handleUpdateInvite}
|
||||
onDeleteInvite={handleDeleteInvite}
|
||||
onRetrySync={handleRetrySync}
|
||||
onCreateClient={handleCreateClient}
|
||||
onUpdateClient={handleUpdateClient}
|
||||
onDeleteClient={handleDeleteClient}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onUpdateMembership={handleUpdateMembership}
|
||||
onDeleteMembership={handleDeleteMembership}
|
||||
pendingAccessAssignments={pendingAccessAssignments}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onUpdateGroup={handleUpdateGroup}
|
||||
onDeleteGroup={handleDeleteGroup}
|
||||
onUpdateService={handleUpdateService}
|
||||
onReorderServices={handleReorderServices}
|
||||
onCreateService={handleCreateService}
|
||||
onDeleteService={handleDeleteService}
|
||||
/>
|
||||
) : null}
|
||||
{profileSettingsOpen && activeProfileUser ? (
|
||||
<ProfileSettingsPanel
|
||||
user={activeProfileUser}
|
||||
onClose={() => setProfileSettingsOpen(false)}
|
||||
onSaveProfile={handleUpdateOwnProfile}
|
||||
onChangePassword={handleUpdateOwnPassword}
|
||||
/>
|
||||
) : null}
|
||||
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function syncLauncherServiceLinks(data: LauncherData): LauncherData {
|
||||
return {
|
||||
...data,
|
||||
services: data.services.map(syncServiceLaunchLink),
|
||||
};
|
||||
}
|
||||
|
||||
function accessAssignmentKey(userId: string, serviceId: string) {
|
||||
return `${userId}:${serviceId}`;
|
||||
}
|
||||
|
||||
function canUseAdminApi(session: AuthSession): boolean {
|
||||
return (
|
||||
session.authenticated &&
|
||||
(session.isSuperAdmin || session.groups.includes("nodedc:launcher:admin") || session.groups.includes("nodedc:superadmin"))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAuthenticatedContext(
|
||||
data: LauncherData,
|
||||
session: AuthenticatedSession,
|
||||
currentProfileId: string,
|
||||
currentClientId: string
|
||||
): { profileId: string; clientId: string } {
|
||||
const sessionEmail = session.user.email?.toLowerCase();
|
||||
const sessionSub = session.user.sub;
|
||||
const profile =
|
||||
data.users.find(
|
||||
(user) =>
|
||||
(sessionSub && user.authentikUserId === sessionSub) ||
|
||||
(sessionEmail && user.email.toLowerCase() === sessionEmail)
|
||||
) ??
|
||||
(session.isSuperAdmin ? data.users.find((user) => user.id === "user_root") : undefined) ??
|
||||
data.users.find((user) => user.id === currentProfileId) ??
|
||||
data.users[0];
|
||||
|
||||
if (!profile) {
|
||||
return { profileId: currentProfileId, clientId: currentClientId };
|
||||
}
|
||||
|
||||
return {
|
||||
profileId: profile.id,
|
||||
clientId: resolveDefaultClientId(data, profile.id, currentClientId),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string {
|
||||
const user = data.users.find((item) => item.id === userId);
|
||||
const isRoot = user?.id === "user_root";
|
||||
const availableClientIds = isRoot
|
||||
? data.clients.map((client) => client.id)
|
||||
: data.memberships.filter((membership) => membership.userId === userId && membership.status === "active").map((membership) => membership.clientId);
|
||||
|
||||
if (requestedClientId && availableClientIds.includes(requestedClientId)) {
|
||||
return requestedClientId;
|
||||
}
|
||||
|
||||
const defaultClientId = profileOptions.find((profile) => profile.userId === userId)?.defaultClientId;
|
||||
|
||||
if (defaultClientId && availableClientIds.includes(defaultClientId)) {
|
||||
return defaultClientId;
|
||||
}
|
||||
|
||||
return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId;
|
||||
}
|
||||
|
||||
function AuthStateScreen({
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
loginUrl,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
error?: string | null;
|
||||
loginUrl?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="launcher-app">
|
||||
<main
|
||||
style={{
|
||||
display: "grid",
|
||||
minHeight: "100vh",
|
||||
placeItems: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
width: "min(34rem, 100%)",
|
||||
gap: "1rem",
|
||||
padding: "2rem",
|
||||
borderRadius: "1.75rem",
|
||||
background: "rgba(255, 255, 255, 0.08)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<img src="/nodedc-logo.svg" alt="NODE.DC" style={{ justifySelf: "center", width: "11rem" }} />
|
||||
<h1 style={{ margin: 0 }}>{title}</h1>
|
||||
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
|
||||
{error ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{error}</p> : null}
|
||||
{loginUrl ? (
|
||||
<button className="button button--primary" type="button" onClick={() => window.location.assign(loginUrl)}>
|
||||
Войти
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ export interface Client {
|
|||
type: ClientType;
|
||||
name: string;
|
||||
legalName?: string | null;
|
||||
inn?: string | null;
|
||||
status: ClientStatus;
|
||||
contractStartsAt?: string | null;
|
||||
contractEndsAt?: string | null;
|
||||
paidUntil?: string | null;
|
||||
demoEndsAt?: string | null;
|
||||
contactName?: string | null;
|
||||
contactEmail?: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { Service } from "./types";
|
||||
|
||||
export function getServiceLaunchLink(service: Pick<Service, "url" | "launchUrl">): string {
|
||||
return service.launchUrl?.trim() || service.url.trim();
|
||||
}
|
||||
|
||||
export function createServiceLaunchLinkPatch(value: string): Pick<Service, "url" | "launchUrl"> {
|
||||
const launchLink = value.trim();
|
||||
|
||||
return {
|
||||
url: launchLink,
|
||||
launchUrl: launchLink || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncServiceLaunchLink(service: Service): Service {
|
||||
return {
|
||||
...service,
|
||||
...createServiceLaunchLinkPatch(getServiceLaunchLink(service)),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import type { LauncherServiceView, MediaKind } from "./types";
|
||||
|
||||
export const DEFAULT_AMBIENT_MEDIA = "/storage/default.gif";
|
||||
export const DEFAULT_AMBIENT_MEDIA_KIND: MediaKind = "gif";
|
||||
|
||||
export function resolveAmbientMedia(service?: LauncherServiceView): { src: string; kind: MediaKind } {
|
||||
return {
|
||||
src: service?.media.ambientVideo ?? DEFAULT_AMBIENT_MEDIA,
|
||||
kind: service?.media.ambientKind ?? DEFAULT_AMBIENT_MEDIA_KIND,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ export interface LauncherUser {
|
|||
authentikUserId?: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string | null;
|
||||
position?: string | null;
|
||||
notes?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
globalStatus: LauncherUserStatus;
|
||||
createdAt: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||
import type { LauncherData } from "./mockApi";
|
||||
|
||||
export type AdminAccessAssignmentValue = Exclude<ServiceAppRole, "owner"> | "deny" | "unset";
|
||||
|
||||
export interface ControlPlaneSnapshot {
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
source: string;
|
||||
};
|
||||
counts: Record<keyof LauncherData, number>;
|
||||
data: LauncherData;
|
||||
}
|
||||
|
||||
export interface ControlPlaneMutationResult {
|
||||
data: LauncherData;
|
||||
provisioning?: {
|
||||
authentikUserId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
groups: string[];
|
||||
created: boolean;
|
||||
temporaryPassword: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
|
||||
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
|
||||
}
|
||||
|
||||
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminClient(clientId: string, patch: Partial<Client>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/clients/${encodeURIComponent(clientId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminClient(clientId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/clients/${encodeURIComponent(clientId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateAdminUserProfile(userId: string, patch: Partial<LauncherUser>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/users/${encodeURIComponent(userId)}/profile`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: {
|
||||
clientId: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role?: ClientMembership["role"];
|
||||
groupIds?: string[];
|
||||
provisionAuth?: boolean;
|
||||
generatePassword?: boolean;
|
||||
password?: string;
|
||||
}): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function provisionAdminUserAuthentik(
|
||||
userId: string,
|
||||
payload: { generatePassword?: boolean; password?: string } = {}
|
||||
): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/users/${encodeURIComponent(userId)}/provision-authentik`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminMembership(
|
||||
membershipId: string,
|
||||
patch: Partial<ClientMembership>
|
||||
): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminMembership(membershipId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminGroup(groupId: string, patch: Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/groups/${encodeURIComponent(groupId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminGroup(groupId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createAdminService(payload: Partial<Service> = {}): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/services", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminService(serviceId: string, patch: Partial<Service>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/services/${encodeURIComponent(serviceId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderAdminServices(orderedServiceIds: string[]): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/services/reorder", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ orderedServiceIds }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminService(serviceId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/services/${encodeURIComponent(serviceId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createAdminInvite(
|
||||
payload: Pick<Invite, "clientId" | "email" | "role">
|
||||
): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/invites", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminInvite(inviteId: string, patch: Partial<Invite>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/invites/${encodeURIComponent(inviteId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminInvite(inviteId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function setAdminUserServiceAccess(payload: {
|
||||
userId: string;
|
||||
serviceId: string;
|
||||
value: AdminAccessAssignmentValue;
|
||||
}): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/access/user-service", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertAdminGrant(payload: Partial<ServiceGrant>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/access/grants", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertAdminException(payload: Partial<ServiceAccessException>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/access/exceptions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryAdminSync(syncId: string): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/sync/${encodeURIComponent(syncId)}/retry`, { method: "POST" });
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
return payload.error ?? response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
export interface AuthUser {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
preferredUsername: string | null;
|
||||
avatarUrl: string | null;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedSession {
|
||||
authenticated: true;
|
||||
user: AuthUser;
|
||||
groups: string[];
|
||||
isSuperAdmin: boolean;
|
||||
logoutUrl: string;
|
||||
}
|
||||
|
||||
export interface UnauthenticatedSession {
|
||||
authenticated: false;
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
export type AuthSession = AuthenticatedSession | UnauthenticatedSession;
|
||||
|
||||
export interface LauncherAuthApp {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
openUrl: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
requiredGroups: string[];
|
||||
matchedGroups: string[];
|
||||
hasAccess: boolean;
|
||||
accessReason: string;
|
||||
media?: {
|
||||
icon?: string | null;
|
||||
coverImage?: string | null;
|
||||
accentColor?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthSession(): Promise<AuthSession> {
|
||||
const response = await fetch("/api/me", { cache: "no-store" });
|
||||
|
||||
if (response.status === 401) {
|
||||
return (await response.json()) as UnauthenticatedSession;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, "Не удалось получить сессию платформы"));
|
||||
}
|
||||
|
||||
return (await response.json()) as AuthenticatedSession;
|
||||
}
|
||||
|
||||
export async function fetchAvailableApps(): Promise<LauncherAuthApp[]> {
|
||||
const response = await fetch("/api/apps", { cache: "no-store" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, "Не удалось получить список приложений"));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { apps?: LauncherAuthApp[] };
|
||||
return payload.apps ?? [];
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
return payload.error ?? fallback;
|
||||
} catch {
|
||||
return response.statusText || fallback;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAc
|
|||
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import { getServiceLaunchLink } from "../../entities/service/links";
|
||||
import type { LauncherServiceView, Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type {
|
||||
|
|
@ -29,6 +30,7 @@ export interface AuthentikClaimsMock {
|
|||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
groups: string[];
|
||||
activeClientId: string;
|
||||
}
|
||||
|
|
@ -97,40 +99,16 @@ export const initialLauncherData: LauncherData = {
|
|||
export const profileOptions: ProfileOption[] = [
|
||||
{
|
||||
userId: "user_root",
|
||||
label: "Root Admin",
|
||||
description: "Полный каталог и все клиенты",
|
||||
label: "DC Touch",
|
||||
description: "NODE.DC superadmin",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_ivan",
|
||||
label: "Client Owner",
|
||||
description: "Иван, владелец Ромашки и админ демо-клиента",
|
||||
userId: "user_silver_psih",
|
||||
label: "Silver Psy",
|
||||
description: "DCTOUCH manager",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_vera",
|
||||
label: "Client Admin",
|
||||
description: "Вера, админ ООО Ромашка",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_vasya",
|
||||
label: "Member",
|
||||
description: "Василий, обычный участник",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_lena",
|
||||
label: "Member + deny",
|
||||
description: "Лена, участник с deny-исключением",
|
||||
defaultClientId: "client_romashka",
|
||||
},
|
||||
{
|
||||
userId: "user_maria",
|
||||
label: "Client Owner demo",
|
||||
description: "Мария, владелец демо-клиента",
|
||||
defaultClientId: "client_roga_kopyta",
|
||||
},
|
||||
];
|
||||
|
||||
export function buildMe(data: LauncherData, userId: string, requestedClientId?: string): MeResponse {
|
||||
|
|
@ -217,7 +195,7 @@ export function buildLauncherServices(data: LauncherData, userId: string, active
|
|||
status: service.status,
|
||||
userAccess: effectiveAccess.allowed ? ("allowed" as const) : ("denied" as const),
|
||||
appRole: effectiveAccess.appRole,
|
||||
openUrl: effectiveAccess.openEnabled ? service.launchUrl ?? service.url : null,
|
||||
openUrl: effectiveAccess.openEnabled ? getServiceLaunchLink(service) || null : null,
|
||||
accentColor: service.accentColor,
|
||||
media: {
|
||||
icon: service.iconUrl,
|
||||
|
|
@ -236,7 +214,7 @@ export function buildLauncherServices(data: LauncherData, userId: string, active
|
|||
effectiveAccess,
|
||||
};
|
||||
})
|
||||
.filter((service) => isRoot || service.effectiveAccess.visible);
|
||||
.filter((service) => isRoot || service.status !== "hidden");
|
||||
}
|
||||
|
||||
export function buildAccessMatrix(data: LauncherData, clientId: string, includeAllServices: boolean): AccessMatrix {
|
||||
|
|
|
|||
|
|
@ -12,40 +12,17 @@ export const mockClients: Client[] = [
|
|||
{
|
||||
id: "client_romashka",
|
||||
type: "company",
|
||||
name: "ООО Ромашка",
|
||||
legalName: "ООО Ромашка",
|
||||
name: "DCTOUCH",
|
||||
legalName: "ООО ДИСИТАЧ",
|
||||
status: "active",
|
||||
contractStartsAt: "2026-05-04T00:00:00.000Z",
|
||||
contractEndsAt: null,
|
||||
paidUntil: null,
|
||||
demoEndsAt: null,
|
||||
contactName: "Иван Петров",
|
||||
contactEmail: "ivan@romashka.ru",
|
||||
notes: "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "client_roga_kopyta",
|
||||
type: "company",
|
||||
name: "ООО Рога и Копыта",
|
||||
legalName: "ООО Рога и Копыта",
|
||||
status: "demo",
|
||||
demoEndsAt: "2026-06-01T00:00:00Z",
|
||||
contactName: "Мария Иванова",
|
||||
contactEmail: "maria@example.ru",
|
||||
notes: "Клиент на демо-доступе, подключены только базовые сервисы.",
|
||||
createdAt: "2026-04-10T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "client_private_architect",
|
||||
type: "person",
|
||||
name: "Илья Архитектор",
|
||||
legalName: null,
|
||||
status: "suspended",
|
||||
demoEndsAt: "2026-04-20T00:00:00Z",
|
||||
contactName: "Илья Архитектор",
|
||||
contactEmail: "ilya@example.ru",
|
||||
notes: "Пример приостановленного частного клиента.",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
contactName: "DC Touch",
|
||||
contactEmail: "dcctouch@gmail.com",
|
||||
notes: "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
|
@ -53,92 +30,44 @@ export const mockClients: Client[] = [
|
|||
export const mockUsers: LauncherUser[] = [
|
||||
{
|
||||
id: "user_root",
|
||||
authentikUserId: "ak-root",
|
||||
name: "Root Admin",
|
||||
email: "root@nodedc.local",
|
||||
authentikUserId: null,
|
||||
name: "DC Touch",
|
||||
email: "dcctouch@gmail.com",
|
||||
phone: null,
|
||||
position: "NODE.DC Super Admin",
|
||||
notes: "Главный супер-администратор NODE.DC.",
|
||||
avatarUrl: null,
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_ivan",
|
||||
authentikUserId: "ak-ivan",
|
||||
name: "Иван Петров",
|
||||
email: "ivan@romashka.ru",
|
||||
id: "user_silver_psih",
|
||||
authentikUserId: null,
|
||||
name: "Silver Psy",
|
||||
email: "silver_psih@yahoo.com",
|
||||
phone: null,
|
||||
position: "Manager",
|
||||
notes: "Живой пользователь из Plane. Требует Authentik invite/sync flow.",
|
||||
avatarUrl: null,
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-01T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_vera",
|
||||
authentikUserId: "ak-vera",
|
||||
name: "Вера Соколова",
|
||||
email: "vera@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-02T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_vasya",
|
||||
authentikUserId: "ak-vasya",
|
||||
name: "Василий Орлов",
|
||||
email: "vasya@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-05T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_lena",
|
||||
authentikUserId: "ak-lena",
|
||||
name: "Лена Волкова",
|
||||
email: "lena@romashka.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-08T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_maria",
|
||||
authentikUserId: "ak-maria",
|
||||
name: "Мария Иванова",
|
||||
email: "maria@example.ru",
|
||||
globalStatus: "active",
|
||||
createdAt: "2026-04-10T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "user_blocked",
|
||||
authentikUserId: "ak-blocked",
|
||||
name: "Олег Заблокирован",
|
||||
email: "oleg@romashka.ru",
|
||||
globalStatus: "blocked",
|
||||
createdAt: "2026-04-12T10:00:00Z",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMemberships: ClientMembership[] = [
|
||||
membership("mem_ivan_romashka", "client_romashka", "user_ivan", "client_owner"),
|
||||
membership("mem_vera_romashka", "client_romashka", "user_vera", "client_admin"),
|
||||
membership("mem_vasya_romashka", "client_romashka", "user_vasya", "member"),
|
||||
membership("mem_lena_romashka", "client_romashka", "user_lena", "member"),
|
||||
membership("mem_blocked_romashka", "client_romashka", "user_blocked", "member", "disabled"),
|
||||
membership("mem_maria_roga", "client_roga_kopyta", "user_maria", "client_owner"),
|
||||
membership("mem_ivan_roga", "client_roga_kopyta", "user_ivan", "client_admin"),
|
||||
membership("mem_dc_touch_dctouch", "client_romashka", "user_root", "client_owner"),
|
||||
membership("mem_silver_psih_dctouch", "client_romashka", "user_silver_psih", "member"),
|
||||
];
|
||||
|
||||
export const mockGroups: ClientGroup[] = [
|
||||
group("group_romashka_leads", "client_romashka", "Руководство", "Собственники и руководители клиента.", [
|
||||
"user_ivan",
|
||||
"user_vera",
|
||||
group("group_dctouch_admins", "client_romashka", "Администраторы", "Администраторы клиента и владельцы платформенного доступа.", [
|
||||
"user_root",
|
||||
]),
|
||||
group("group_romashka_accounting", "client_romashka", "Бухгалтерия", "1C и финансовые сценарии.", [
|
||||
"user_lena",
|
||||
group("group_dctouch_managers", "client_romashka", "Менеджеры", "Рабочая группа менеджеров с доступом к операционному контуру.", [
|
||||
"user_silver_psih",
|
||||
]),
|
||||
group("group_romashka_ops", "client_romashka", "Операторы", "Ежедневная работа в задачах и тендерах.", [
|
||||
"user_vasya",
|
||||
"user_lena",
|
||||
]),
|
||||
group("group_roga_demo", "client_roga_kopyta", "Демо-команда", "Пилотный контур клиента.", ["user_maria", "user_ivan"]),
|
||||
];
|
||||
|
||||
export const mockServices: Service[] = [
|
||||
|
|
@ -150,7 +79,7 @@ export const mockServices: Service[] = [
|
|||
description: "Сборка, запуск и мониторинг агентных workflow.",
|
||||
fullDescription:
|
||||
"NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
|
||||
url: "https://dev.handhdc.ru",
|
||||
url: "https://dev.handhdc.ru/sso/launch",
|
||||
launchUrl: "https://dev.handhdc.ru/sso/launch",
|
||||
accentColor: "#B5FF5A",
|
||||
fallbackGradient: "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)",
|
||||
|
|
@ -168,7 +97,7 @@ export const mockServices: Service[] = [
|
|||
subtitle: "Операционный слой",
|
||||
description: "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
|
||||
fullDescription: "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
|
||||
url: "https://tasks.handhdc.ru",
|
||||
url: "https://tasks.handhdc.ru/sso/launch",
|
||||
launchUrl: "https://tasks.handhdc.ru/sso/launch",
|
||||
accentColor: "#D7C8FF",
|
||||
fallbackGradient: "linear-gradient(132deg, rgba(215, 200, 255, 0.82), rgba(51, 41, 79, 0.9) 46%, #0B0D10 84%)",
|
||||
|
|
@ -186,7 +115,7 @@ export const mockServices: Service[] = [
|
|||
subtitle: "Бухгалтерский ассистент",
|
||||
description: "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
|
||||
fullDescription: "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
|
||||
url: "https://1c.handhdc.ru",
|
||||
url: "https://1c.handhdc.ru/sso/launch",
|
||||
launchUrl: "https://1c.handhdc.ru/sso/launch",
|
||||
accentColor: "#8FD7FF",
|
||||
fallbackGradient: "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)",
|
||||
|
|
@ -204,7 +133,7 @@ export const mockServices: Service[] = [
|
|||
subtitle: "Госзакупки и тендеры",
|
||||
description: "Поиск, анализ и подготовка тендерных решений.",
|
||||
fullDescription: "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
|
||||
url: "https://tender.handhdc.ru",
|
||||
url: "https://tender.handhdc.ru/sso/launch",
|
||||
launchUrl: "https://tender.handhdc.ru/sso/launch",
|
||||
accentColor: "#FFD166",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)",
|
||||
|
|
@ -222,7 +151,7 @@ export const mockServices: Service[] = [
|
|||
subtitle: "3D и пространственные данные",
|
||||
description: "Просмотр цифровых двойников, карт и объектных сцен.",
|
||||
fullDescription: "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
|
||||
url: "https://twin.handhdc.ru",
|
||||
url: "https://twin.handhdc.ru/sso/launch",
|
||||
launchUrl: "https://twin.handhdc.ru/sso/launch",
|
||||
accentColor: "#76E4F7",
|
||||
fallbackGradient: "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
|
||||
|
|
@ -240,7 +169,7 @@ export const mockServices: Service[] = [
|
|||
subtitle: "Будущие модули",
|
||||
description: "Скрытый каталог модулей для root-admin preview.",
|
||||
fullDescription: "Площадка для будущих цифровых модулей NODE.DC.",
|
||||
url: "https://dm.handhdc.ru",
|
||||
url: "https://dm.handhdc.ru/sso/launch",
|
||||
launchUrl: "https://dm.handhdc.ru/sso/launch",
|
||||
accentColor: "#FF9AC2",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(255, 154, 194, 0.78), rgba(76, 41, 64, 0.9) 44%, #090B0F 86%)",
|
||||
|
|
@ -259,7 +188,7 @@ export const mockServices: Service[] = [
|
|||
description: "Отключённый сервис для проверки диагностики root-admin.",
|
||||
fullDescription: "Не показывается обычным пользователям, виден root-admin в каталоге.",
|
||||
url: "https://internal.handhdc.ru",
|
||||
launchUrl: null,
|
||||
launchUrl: "https://internal.handhdc.ru",
|
||||
accentColor: "#F97373",
|
||||
fallbackGradient: "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
|
||||
status: "disabled",
|
||||
|
|
@ -272,82 +201,43 @@ export const mockServices: Service[] = [
|
|||
];
|
||||
|
||||
export const mockGrants: ServiceGrant[] = [
|
||||
grant("grant_romashka_task", "service_task_manager", "client", "client_romashka", "member"),
|
||||
grant("grant_romashka_nodedc_leads", "service_nodedc", "group", "group_romashka_leads", "admin"),
|
||||
grant("grant_romashka_1c_accounting", "service_1c", "group", "group_romashka_accounting", "member"),
|
||||
grant("grant_romashka_tender_ops", "service_tender", "group", "group_romashka_ops", "viewer"),
|
||||
grant("grant_romashka_twin_vasya", "service_digital_twin", "user", "user_vasya", "viewer"),
|
||||
grant("grant_roga_task", "service_task_manager", "client", "client_roga_kopyta", "member"),
|
||||
grant("grant_roga_nodedc", "service_nodedc", "client", "client_roga_kopyta", "viewer"),
|
||||
grant("grant_dctouch_task_admins", "service_task_manager", "group", "group_dctouch_admins", "admin"),
|
||||
grant("grant_dctouch_task_managers", "service_task_manager", "group", "group_dctouch_managers", "member"),
|
||||
grant("grant_dctouch_nodedc_admins", "service_nodedc", "group", "group_dctouch_admins", "admin"),
|
||||
];
|
||||
|
||||
export const mockExceptions: ServiceAccessException[] = [
|
||||
{
|
||||
id: "exception_lena_task_deny",
|
||||
serviceId: "service_task_manager",
|
||||
userId: "user_lena",
|
||||
type: "deny",
|
||||
reason: "Индивидуально отключён Task Manager на период ревизии доступа.",
|
||||
createdAt: "2026-04-28T10:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
export const mockExceptions: ServiceAccessException[] = [];
|
||||
|
||||
export const mockInvites: Invite[] = [
|
||||
{
|
||||
id: "invite_romashka_analyst",
|
||||
clientId: "client_romashka",
|
||||
email: "analyst@romashka.ru",
|
||||
role: "member",
|
||||
invitedByUserId: "user_ivan",
|
||||
token: "romashka-analyst-demo",
|
||||
expiresAt: "2026-05-15T12:00:00Z",
|
||||
status: "sent",
|
||||
createdAt: "2026-04-30T12:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "invite_roga_admin",
|
||||
clientId: "client_roga_kopyta",
|
||||
email: "ops@example.ru",
|
||||
role: "client_admin",
|
||||
invitedByUserId: "user_maria",
|
||||
token: "roga-admin-demo",
|
||||
expiresAt: "2026-05-18T12:00:00Z",
|
||||
status: "created",
|
||||
createdAt: "2026-04-30T14:00:00Z",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
export const mockInvites: Invite[] = [];
|
||||
|
||||
export const mockSyncStatuses: SyncStatus[] = [
|
||||
sync("sync_romashka_auth", "client_romashka", "ООО Ромашка", "client", "authentik", "synced"),
|
||||
sync("sync_task_auth", "service_task_manager", "Task Manager", "service", "authentik", "synced"),
|
||||
sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"),
|
||||
sync("sync_dc_touch_authentik", "user_root", "dcctouch@gmail.com", "user", "authentik", "synced"),
|
||||
sync(
|
||||
"sync_lena_task",
|
||||
"exception_lena_task_deny",
|
||||
"Deny: Лена / Task Manager",
|
||||
"grant",
|
||||
"task_manager",
|
||||
"sync_silver_psih_authentik",
|
||||
"user_silver_psih",
|
||||
"silver_psih@yahoo.com",
|
||||
"user",
|
||||
"authentik",
|
||||
"pending",
|
||||
null
|
||||
),
|
||||
sync(
|
||||
"sync_roga_nodedc",
|
||||
"client_roga_kopyta",
|
||||
"ООО Рога и Копыта",
|
||||
"client",
|
||||
"nodedc",
|
||||
"error",
|
||||
"OIDC binding ещё не создан для demo-клиента."
|
||||
"Пользователь найден в Plane, но ещё не создан в Authentik через Launcher invite/sync flow."
|
||||
),
|
||||
sync("sync_dctouch_groups_authentik", "client_romashka:groups", "DCTOUCH groups", "group", "authentik", "pending"),
|
||||
sync("sync_task_manager_authentik", "service_task_manager", "OPERATIONAL CORE", "service", "authentik", "synced"),
|
||||
];
|
||||
|
||||
export const mockAuditEvents: AuditEvent[] = [
|
||||
audit("audit_1", "2026-05-01T08:40:00Z", "user_root", "Root Admin", "Создан сервис", "service", "Digital Modules", "success", null),
|
||||
audit("audit_2", "2026-05-01T08:20:00Z", "user_ivan", "Иван Петров", "Создан invite", "invite", "analyst@romashka.ru", "success", "Срок действия до 15.05.2026"),
|
||||
audit("audit_3", "2026-04-30T17:10:00Z", "user_root", "Root Admin", "Создано deny-исключение", "access", "Лена / Task Manager", "warning", "Индивидуальное правило перекрыло client grant."),
|
||||
audit("audit_4", "2026-04-30T16:00:00Z", "user_root", "Root Admin", "Ошибка синхронизации", "sync", "ООО Рога и Копыта / NodeDC", "error", "Нет application binding."),
|
||||
audit(
|
||||
"audit_live_seed_control_plane",
|
||||
"2026-05-04T00:00:00.000Z",
|
||||
"system",
|
||||
"NODE.DC seed",
|
||||
"Применён live seed control-plane",
|
||||
"control_plane",
|
||||
"Launcher users and access",
|
||||
"success",
|
||||
"Demo-участники удалены из runtime storage. Оставлены dcctouch@gmail.com и silver_psih@yahoo.com."
|
||||
),
|
||||
];
|
||||
|
||||
function membership(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import type { ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||
import type { LauncherData } from "./mockApi";
|
||||
|
||||
export interface ProfileResponse {
|
||||
user: LauncherUser;
|
||||
memberships: ClientMembership[];
|
||||
}
|
||||
|
||||
export interface ProfileMutationResult {
|
||||
data: LauncherData;
|
||||
}
|
||||
|
||||
export async function fetchOwnProfile(): Promise<ProfileResponse> {
|
||||
return requestJson<ProfileResponse>("/api/profile");
|
||||
}
|
||||
|
||||
export async function updateOwnProfile(patch: Partial<LauncherUser>): Promise<ProfileMutationResult> {
|
||||
return requestJson<ProfileMutationResult>("/api/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateOwnPassword(newPassword: string): Promise<ProfileMutationResult> {
|
||||
return requestJson<ProfileMutationResult>("/api/profile/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
return payload.error ?? response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,260 @@
|
|||
import { CalendarDays } from "lucide-react";
|
||||
import type { SVGProps } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { enUS, ru } from "date-fns/locale";
|
||||
import { DayPicker, type DateRange, type Matcher } from "react-day-picker";
|
||||
import { cn } from "../lib/cn";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { NodeDcDropdown } from "./Dropdown";
|
||||
import "./calendar.css";
|
||||
|
||||
export type NodeDcDateInput = Date | string | null | undefined;
|
||||
export type NodeDcWeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export interface NodeDcCalendarRangePreview {
|
||||
from?: NodeDcDateInput;
|
||||
to?: NodeDcDateInput;
|
||||
}
|
||||
|
||||
export interface NodeDcCalendarProps {
|
||||
value: string | null;
|
||||
rangeStart?: string | null;
|
||||
rangeEnd?: string | null;
|
||||
rangePreview?: NodeDcCalendarRangePreview;
|
||||
minDate?: NodeDcDateInput;
|
||||
maxDate?: NodeDcDateInput;
|
||||
weekStartsOn?: NodeDcWeekStartsOn;
|
||||
clearable?: boolean;
|
||||
resetLabel?: string;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export interface NodeDcDateFieldProps {
|
||||
value: string | null;
|
||||
rangeStart?: string | null;
|
||||
rangeEnd?: string | null;
|
||||
rangePreview?: NodeDcCalendarRangePreview;
|
||||
minDate?: NodeDcDateInput;
|
||||
maxDate?: NodeDcDateInput;
|
||||
weekStartsOn?: NodeDcWeekStartsOn;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
emptyLabel?: string;
|
||||
clearable?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
resetLabel?: string;
|
||||
popoverWidth?: number;
|
||||
popoverMinWidth?: number;
|
||||
}
|
||||
|
||||
export function NodeDcDateField({
|
||||
value,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
rangePreview,
|
||||
minDate,
|
||||
maxDate,
|
||||
weekStartsOn,
|
||||
label,
|
||||
onChange,
|
||||
className,
|
||||
triggerClassName,
|
||||
placement = "bottom-start",
|
||||
emptyLabel = "Нет",
|
||||
clearable = true,
|
||||
closeOnSelect = true,
|
||||
resetLabel,
|
||||
popoverWidth = 328,
|
||||
popoverMinWidth = 328,
|
||||
}: NodeDcDateFieldProps) {
|
||||
return (
|
||||
<NodeDcDropdown
|
||||
className={cn("nodedc-ui-date-field", className)}
|
||||
placement={placement}
|
||||
minWidth={popoverMinWidth}
|
||||
width={popoverWidth}
|
||||
surfaceClassName="nodedc-ui-calendar-popover"
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className={cn("nodedc-ui-date-trigger", triggerClassName)}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<CalendarDays size={14} strokeWidth={1.75} />
|
||||
<span>{value ? formatDate(value) : emptyLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<NodeDcCalendar
|
||||
value={value}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
rangePreview={rangePreview}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
weekStartsOn={weekStartsOn}
|
||||
clearable={clearable}
|
||||
resetLabel={resetLabel}
|
||||
onChange={(nextValue) => {
|
||||
onChange(nextValue);
|
||||
if (closeOnSelect) close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDcCalendar({
|
||||
value,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
rangePreview,
|
||||
minDate,
|
||||
maxDate,
|
||||
weekStartsOn = 0,
|
||||
clearable = true,
|
||||
resetLabel = "Сбросить дату",
|
||||
onChange,
|
||||
}: NodeDcCalendarProps) {
|
||||
const selectedDate = value ? parseDate(value) : null;
|
||||
const previewRange = useMemo(
|
||||
() => normalizeRange(rangePreview?.from ?? rangeStart, rangePreview?.to ?? rangeEnd),
|
||||
[rangeEnd, rangePreview?.from, rangePreview?.to, rangeStart]
|
||||
);
|
||||
const disabledDays = useMemo(() => buildDisabledDays(minDate, maxDate), [maxDate, minDate]);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startMonth = useMemo(() => new Date(currentYear - 30, 0, 1), [currentYear]);
|
||||
const endMonth = useMemo(() => new Date(currentYear + 30, 11, 31), [currentYear]);
|
||||
const locale = getCalendarLocale();
|
||||
|
||||
const sharedCalendarProps = {
|
||||
captionLayout: "dropdown" as const,
|
||||
className: "nodedc-ui-calendar nodedc-calendar-shell",
|
||||
components: { Chevron: CalendarChevron },
|
||||
defaultMonth: selectedDate ?? previewRange?.to ?? previewRange?.from ?? undefined,
|
||||
endMonth,
|
||||
fixedWeeks: true,
|
||||
initialFocus: true,
|
||||
locale,
|
||||
showOutsideDays: true,
|
||||
startMonth,
|
||||
weekStartsOn,
|
||||
disabled: disabledDays,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodedc-ui-calendar-frame" data-nodedc-calendar="true">
|
||||
{previewRange ? (
|
||||
<DayPicker
|
||||
{...sharedCalendarProps}
|
||||
mode="range"
|
||||
selected={previewRange}
|
||||
onDayClick={(date, modifiers) => {
|
||||
if (modifiers.disabled) return;
|
||||
onChange(toLocalIso(date));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DayPicker
|
||||
{...sharedCalendarProps}
|
||||
mode="single"
|
||||
selected={selectedDate ?? undefined}
|
||||
onSelect={(date) => onChange(date ? toLocalIso(date) : null)}
|
||||
/>
|
||||
)}
|
||||
{clearable && value ? (
|
||||
<button className="nodedc-ui-calendar__reset" type="button" onClick={() => onChange(null)}>
|
||||
{resetLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarChevron({ className, orientation, ...props }: SVGProps<SVGSVGElement> & { orientation?: "up" | "down" | "left" | "right" }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"nodedc-ui-calendar__chevron",
|
||||
orientation === "right" && "nodedc-ui-calendar__chevron--right",
|
||||
orientation === "down" && "nodedc-ui-calendar__chevron--down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.55757 3.55708C9.80165 3.313 10.1983 3.313 10.4423 3.55708C10.6864 3.80116 10.6864 4.19777 10.4423 4.44185L6.88472 7.99946L10.4423 11.5571C10.6864 11.8012 10.6864 12.1978 10.4423 12.4418C10.1983 12.6859 9.80165 12.6859 9.55757 12.4418L5.55757 8.44185C5.31349 8.19777 5.31349 7.80116 5.55757 7.55708L9.55757 3.55708Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarLocale() {
|
||||
const localeCode =
|
||||
typeof document !== "undefined" && document.documentElement.lang
|
||||
? document.documentElement.lang
|
||||
: typeof navigator !== "undefined"
|
||||
? navigator.language
|
||||
: "ru-RU";
|
||||
|
||||
return localeCode.toLowerCase().startsWith("ru") ? ru : enUS;
|
||||
}
|
||||
|
||||
function normalizeRange(startValue?: NodeDcDateInput, endValue?: NodeDcDateInput): DateRange | undefined {
|
||||
if (!startValue || !endValue) return undefined;
|
||||
|
||||
const start = parseDate(startValue);
|
||||
const end = parseDate(endValue);
|
||||
|
||||
if (!start || !end) return undefined;
|
||||
|
||||
if (start <= end) return { from: start, to: end };
|
||||
return { from: end, to: start };
|
||||
}
|
||||
|
||||
function buildDisabledDays(minDate?: NodeDcDateInput, maxDate?: NodeDcDateInput): Matcher[] {
|
||||
const disabledDays: Matcher[] = [];
|
||||
const min = minDate ? parseDate(minDate) : null;
|
||||
const max = maxDate ? parseDate(maxDate) : null;
|
||||
|
||||
if (min) disabledDays.push({ before: min });
|
||||
if (max) disabledDays.push({ after: max });
|
||||
|
||||
return disabledDays;
|
||||
}
|
||||
|
||||
function parseDate(value: Date | string): Date | null {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) return date;
|
||||
|
||||
const fallbackDate = new Date(`${value.slice(0, 10)}T00:00:00`);
|
||||
return Number.isNaN(fallbackDate.getTime()) ? null : fallbackDate;
|
||||
}
|
||||
|
||||
function toLocalDateInput(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalIso(date: Date): string {
|
||||
const day = toLocalDateInput(date);
|
||||
return new Date(`${day}T00:00:00`).toISOString();
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { useCallback, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
type NodeDcDropdownPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
|
||||
interface NodeDcDropdownTriggerApi {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
setTriggerRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface NodeDcDropdownProps {
|
||||
trigger: (api: NodeDcDropdownTriggerApi) => ReactNode;
|
||||
children: ReactNode | ((api: { close: () => void }) => ReactNode);
|
||||
placement?: NodeDcDropdownPlacement;
|
||||
minWidth?: number;
|
||||
width?: number;
|
||||
offset?: number;
|
||||
className?: string;
|
||||
surfaceClassName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NodeDcDropdown({
|
||||
trigger,
|
||||
children,
|
||||
placement = "bottom-start",
|
||||
minWidth = 180,
|
||||
width,
|
||||
offset = 8,
|
||||
className,
|
||||
surfaceClassName,
|
||||
disabled = false,
|
||||
}: NodeDcDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [triggerEl, setTriggerEl] = useState<HTMLElement | null>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>();
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerEl) return;
|
||||
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const surfaceWidth = width ?? Math.max(rect.width, minWidth);
|
||||
const viewportPadding = 8;
|
||||
const alignedLeft = placement.endsWith("end") ? rect.right - surfaceWidth : rect.left;
|
||||
const left = Math.min(Math.max(viewportPadding, alignedLeft), window.innerWidth - surfaceWidth - viewportPadding);
|
||||
|
||||
if (placement.startsWith("top")) {
|
||||
setStyle({
|
||||
bottom: window.innerHeight - rect.top + offset,
|
||||
left,
|
||||
width: surfaceWidth,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStyle({
|
||||
top: rect.bottom + offset,
|
||||
left,
|
||||
width: surfaceWidth,
|
||||
});
|
||||
}, [minWidth, offset, placement, triggerEl, width]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (disabled) return;
|
||||
setOpen((current) => !current);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
updatePosition();
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (target && (triggerEl?.contains(target) || target.closest("[data-nodedc-dropdown-surface='true']"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [close, open, triggerEl, updatePosition]);
|
||||
|
||||
const triggerNode = trigger({
|
||||
open,
|
||||
toggle,
|
||||
close,
|
||||
setTriggerRef: setTriggerEl,
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={cn("nodedc-ui-dropdown", className)}>
|
||||
{triggerNode}
|
||||
{open && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className={cn("nodedc-ui-dropdown-surface nodedc-dropdown-surface", surfaceClassName)}
|
||||
data-nodedc-dropdown-surface="true"
|
||||
style={style}
|
||||
>
|
||||
{typeof children === "function" ? children({ close }) : children}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { LogOut, Settings } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
import { initials } from "../lib/format";
|
||||
import { NodeDcDropdown } from "./Dropdown";
|
||||
|
||||
interface NodeDcProfileMenuUser {
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface NodeDcProfileMenuProps {
|
||||
user: NodeDcProfileMenuUser;
|
||||
coverUrl?: string;
|
||||
trigger: (api: { open: boolean; toggle: () => void; setTriggerRef: (node: HTMLElement | null) => void }) => ReactNode;
|
||||
className?: string;
|
||||
onLogout?: () => void;
|
||||
onSettings?: () => void;
|
||||
}
|
||||
|
||||
export function NodeDcProfileMenu({ user, coverUrl = "/storage/default.gif", trigger, className, onLogout, onSettings }: NodeDcProfileMenuProps) {
|
||||
return (
|
||||
<NodeDcDropdown
|
||||
className={className}
|
||||
placement="bottom-end"
|
||||
width={296}
|
||||
surfaceClassName="nodedc-ui-profile-menu"
|
||||
trigger={({ open, toggle, setTriggerRef }) => trigger({ open, toggle, setTriggerRef })}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="nodedc-ui-profile-card">
|
||||
<div className="nodedc-ui-profile-card__cover" style={{ backgroundImage: `url(${coverUrl})` }}>
|
||||
<Avatar user={user} className="nodedc-ui-profile-card__avatar" />
|
||||
<strong>{user.name}</strong>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<button
|
||||
className="nodedc-ui-profile-card__row"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
close();
|
||||
onSettings?.();
|
||||
}}
|
||||
>
|
||||
<Settings size={15} strokeWidth={1.7} />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
<button
|
||||
className="nodedc-ui-profile-card__row"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
close();
|
||||
onLogout?.();
|
||||
}}
|
||||
>
|
||||
<LogOut size={15} strokeWidth={1.7} />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ user, className }: { user: NodeDcProfileMenuUser; className?: string }) {
|
||||
if (user.avatarUrl) {
|
||||
return <img className={cn("nodedc-ui-avatar", className)} src={user.avatarUrl} alt="" />;
|
||||
}
|
||||
|
||||
return <span className={cn("nodedc-ui-avatar", className)}>{initials(user.name)}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
import { NodeDcDropdown } from "./Dropdown";
|
||||
|
||||
export interface NodeDcSelectOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
tone?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NodeDcSelectTriggerApi<T extends string> {
|
||||
open: boolean;
|
||||
selectedOption: NodeDcSelectOption<T>;
|
||||
toggle: () => void;
|
||||
setTriggerRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface NodeDcSelectProps<T extends string> {
|
||||
value: T;
|
||||
options: Array<NodeDcSelectOption<T>>;
|
||||
label: string;
|
||||
onChange: (value: T, option: NodeDcSelectOption<T>) => void;
|
||||
trigger?: (api: NodeDcSelectTriggerApi<T>) => ReactNode;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
menuClassName?: string;
|
||||
optionClassName?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
minMenuWidth?: number;
|
||||
menuWidth?: number;
|
||||
disabled?: boolean;
|
||||
renderOptionLabel?: (option: NodeDcSelectOption<T>) => ReactNode;
|
||||
}
|
||||
|
||||
export function NodeDcSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
onChange,
|
||||
trigger,
|
||||
className,
|
||||
triggerClassName,
|
||||
menuClassName,
|
||||
optionClassName,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Поиск",
|
||||
placement = "bottom-start",
|
||||
minMenuWidth = 180,
|
||||
menuWidth,
|
||||
disabled = false,
|
||||
renderOptionLabel,
|
||||
}: NodeDcSelectProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleOptions = useMemo(() => {
|
||||
if (!normalizedQuery) return options;
|
||||
|
||||
return options.filter((option) => {
|
||||
const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}, [normalizedQuery, options]);
|
||||
|
||||
return (
|
||||
<NodeDcDropdown
|
||||
className={className}
|
||||
placement={placement}
|
||||
minWidth={minMenuWidth}
|
||||
width={menuWidth}
|
||||
disabled={disabled}
|
||||
surfaceClassName={cn("nodedc-ui-select-menu", menuClassName)}
|
||||
trigger={({ open, toggle, setTriggerRef }) =>
|
||||
trigger ? (
|
||||
trigger({ open, selectedOption, toggle, setTriggerRef })
|
||||
) : (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className={cn("nodedc-ui-select-trigger", triggerClassName)}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
>
|
||||
{selectedOption.icon ? <span className="nodedc-ui-select-trigger__icon">{selectedOption.icon}</span> : null}
|
||||
<span className="nodedc-ui-select-trigger__text">{selectedOption.label}</span>
|
||||
<ChevronDown className="nodedc-ui-select-trigger__chevron" size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{searchable ? (
|
||||
<label className="nodedc-ui-dropdown-search">
|
||||
<Search size={14} strokeWidth={1.8} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="nodedc-ui-option-list">
|
||||
{visibleOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={cn("nodedc-ui-option nodedc-dropdown-option", optionClassName)}
|
||||
data-selected={option.value === value}
|
||||
data-tone={option.tone}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (option.disabled) return;
|
||||
onChange(option.value, option);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{option.icon ? <span className="nodedc-ui-option__icon">{option.icon}</span> : null}
|
||||
<span className="nodedc-ui-option__body">
|
||||
<span className="nodedc-ui-option__label">{renderOptionLabel ? renderOptionLabel(option) : option.label}</span>
|
||||
{option.description ? <span className="nodedc-ui-option__description">{option.description}</span> : null}
|
||||
</span>
|
||||
{option.value === value ? <Check className="nodedc-ui-option__check" size={14} strokeWidth={1.8} /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
.nodedc-ui-date-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-ui-date-trigger {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
min-height: 2.08rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.42rem;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.052);
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.7rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-ui-date-trigger:hover,
|
||||
.nodedc-ui-date-trigger:focus-visible,
|
||||
.nodedc-ui-date-trigger[aria-expanded="true"] {
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar-popover {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar-frame {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
padding: 1.05rem 1.5rem 0.75rem;
|
||||
border-radius: 1.18rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar.rdp-root {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: calc(var(--rdp-cell-size) * 7);
|
||||
padding: 0;
|
||||
--rdp-accent-color: rgb(var(--nodedc-accent-rgb));
|
||||
--rdp-background-color: rgba(var(--nodedc-accent-rgb), 0.16);
|
||||
--rdp-dark-background-color: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white);
|
||||
--rdp-range-background-color: var(--rdp-accent-color);
|
||||
--rdp-selected-color: rgb(var(--nodedc-on-accent-rgb));
|
||||
--rdp-today-outline-color: rgb(var(--nodedc-on-accent-rgb));
|
||||
--rdp-outline: 0;
|
||||
--rdp-cell-size: 2.5rem;
|
||||
--rdp-caption-font-size: 1rem;
|
||||
--rdp-caption-navigation-size: 1.25rem;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-root,
|
||||
.nodedc-ui-calendar .rdp-months,
|
||||
.nodedc-ui-calendar .rdp-month {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-month_grid {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-month_caption {
|
||||
min-height: 2.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdowns {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown_root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
opacity: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-caption_label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.24rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 0 0.12rem;
|
||||
font-family: inherit;
|
||||
font-size: var(--rdp-caption-font-size);
|
||||
font-weight: 780;
|
||||
line-height: 1;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown_icon {
|
||||
margin: 0 0 0 0.18rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-nav {
|
||||
position: absolute;
|
||||
top: 0.42rem;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-button_previous,
|
||||
.nodedc-ui-calendar .rdp-button_next,
|
||||
.nodedc-ui-calendar .rdp-dropdown_root {
|
||||
border: 0;
|
||||
border-radius: 0.95rem;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-button_previous,
|
||||
.nodedc-ui-calendar .rdp-button_next {
|
||||
display: inline-flex;
|
||||
width: var(--rdp-caption-navigation-size);
|
||||
height: var(--rdp-caption-navigation-size);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-button_previous:hover,
|
||||
.nodedc-ui-calendar .rdp-button_next:hover,
|
||||
.nodedc-ui-calendar .rdp-button_previous:focus-visible,
|
||||
.nodedc-ui-calendar .rdp-button_next:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__chevron {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__chevron--right {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__chevron--down {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-weekdays,
|
||||
.nodedc-ui-calendar .rdp-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, var(--rdp-cell-size));
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-weekdays,
|
||||
.nodedc-ui-calendar .rdp-week,
|
||||
.nodedc-ui-calendar .rdp-weekday,
|
||||
.nodedc-ui-calendar .rdp-day {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week {
|
||||
height: var(--rdp-cell-size);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-weekday {
|
||||
height: var(--rdp-cell-size);
|
||||
padding: 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 0.75em;
|
||||
font-weight: 720;
|
||||
line-height: var(--rdp-cell-size);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day_button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: var(--rdp-cell-size);
|
||||
max-width: var(--rdp-cell-size);
|
||||
height: var(--rdp-cell-size);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day.rdp-outside:not(.rdp-selected) .rdp-day_button {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day.rdp-disabled:not(.rdp-selected) .rdp-day_button {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day:not(.rdp-selected, .rdp-disabled) .rdp-day_button:hover,
|
||||
.nodedc-ui-calendar .rdp-day:not(.rdp-selected, .rdp-disabled) .rdp-day_button:focus-visible {
|
||||
background: var(--rdp-background-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-selected .rdp-day_button {
|
||||
z-index: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--rdp-accent-color);
|
||||
color: var(--rdp-selected-color);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-selected .rdp-day_button:hover,
|
||||
.nodedc-ui-calendar .rdp-selected .rdp-day_button:focus-visible {
|
||||
background: var(--rdp-accent-color);
|
||||
color: var(--rdp-selected-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-today:not(.rdp-outside) .rdp-day_button {
|
||||
border-radius: 999px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-today:not(.rdp-outside) .rdp-day_button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 1.5px solid var(--rdp-today-outline-color);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-today.rdp-selected .rdp-day_button::after {
|
||||
border-color: var(--rdp-today-outline-color);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start,
|
||||
.nodedc-ui-calendar .rdp-range_middle,
|
||||
.nodedc-ui-calendar .rdp-range_end {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start::before,
|
||||
.nodedc-ui-calendar .rdp-range_middle::before,
|
||||
.nodedc-ui-calendar .rdp-range_end::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
background: var(--rdp-range-background-color);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start::before {
|
||||
border-radius: 999px 0 0 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_middle:first-child::before {
|
||||
border-radius: 999px 0 0 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_end:first-child::before {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_end::before {
|
||||
border-radius: 0 999px 999px 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_middle:last-child::before {
|
||||
border-radius: 0 999px 999px 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_start:last-child::before {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start.rdp-range_end::before {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start .rdp-day_button,
|
||||
.nodedc-ui-calendar .rdp-range_middle .rdp-day_button,
|
||||
.nodedc-ui-calendar .rdp-range_end .rdp-day_button {
|
||||
background: transparent;
|
||||
color: var(--rdp-selected-color);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day.rdp-range_middle .rdp-day_button:hover,
|
||||
.nodedc-ui-calendar .rdp-day.rdp-range_middle .rdp-day_button:focus-visible {
|
||||
background: transparent;
|
||||
color: var(--rdp-selected-color);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__reset {
|
||||
min-height: 2.1rem;
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.76rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__reset:hover {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export { NodeDcDropdown } from "./Dropdown";
|
||||
export { NodeDcDeleteModal, type NodeDcDeleteModalProps } from "./DeleteModal";
|
||||
export {
|
||||
NodeDcDateField,
|
||||
NodeDcCalendar,
|
||||
type NodeDcCalendarProps,
|
||||
type NodeDcDateFieldProps,
|
||||
type NodeDcCalendarRangePreview,
|
||||
type NodeDcDateInput,
|
||||
type NodeDcWeekStartsOn,
|
||||
} from "./Calendar";
|
||||
export { NodeDcProfileMenu } from "./ProfileMenu";
|
||||
export { NodeDcSelect, type NodeDcSelectOption } from "./Select";
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { KeyRound, Save, Upload, X } from "lucide-react";
|
||||
import type { LauncherUser } from "../../entities/user/types";
|
||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||
import { initials } from "../../shared/lib/format";
|
||||
import { Button, IconButton } from "../../shared/ui/Button";
|
||||
|
||||
export function ProfileSettingsPanel({
|
||||
user,
|
||||
onClose,
|
||||
onSaveProfile,
|
||||
onChangePassword,
|
||||
}: {
|
||||
user: LauncherUser;
|
||||
onClose: () => void;
|
||||
onSaveProfile: (patch: Partial<LauncherUser>) => Promise<void>;
|
||||
onChangePassword: (newPassword: string) => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<LauncherUser>(user);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setDraft(user), [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(file: File | undefined) {
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await uploadStorageFile(file);
|
||||
update("avatarUrl", result.url);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
setSavingProfile(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await onSaveProfile({
|
||||
name: draft.name,
|
||||
email: draft.email,
|
||||
phone: draft.phone ?? null,
|
||||
position: draft.position ?? null,
|
||||
avatarUrl: draft.avatarUrl ?? null,
|
||||
});
|
||||
setMessage("Профиль сохранён");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePassword() {
|
||||
if (newPassword.length < 8) {
|
||||
setMessage("Пароль должен быть не короче 8 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPassword(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await onChangePassword(newPassword);
|
||||
setNewPassword("");
|
||||
setMessage("Пароль обновлён");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось обновить пароль");
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="profile-settings-layer" aria-label="Настройки профиля">
|
||||
<section className="profile-settings-panel">
|
||||
<div className="profile-settings-panel__head">
|
||||
<div>
|
||||
<p className="eyebrow">Профиль</p>
|
||||
<h2>Настройки</h2>
|
||||
</div>
|
||||
<IconButton label="Закрыть настройки" className="admin-panel-close" type="button" onClick={onClose}>
|
||||
<X size={15} strokeWidth={1.45} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="profile-settings-panel__body">
|
||||
<div className="profile-settings-avatar-card">
|
||||
{draft.avatarUrl ? (
|
||||
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
|
||||
)}
|
||||
<label className="profile-settings-upload">
|
||||
<Upload size={15} />
|
||||
<span>{uploading ? "Загружаем" : "Загрузить аватар"}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
disabled={uploading}
|
||||
onChange={(event) => void handleAvatarUpload(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="profile-settings-field">
|
||||
<span>Имя</span>
|
||||
<input value={draft.name} onChange={(event) => update("name", event.target.value)} />
|
||||
</label>
|
||||
<label className="profile-settings-field">
|
||||
<span>Email</span>
|
||||
<input value={draft.email} onChange={(event) => update("email", event.target.value)} />
|
||||
</label>
|
||||
<label className="profile-settings-field">
|
||||
<span>Телефон</span>
|
||||
<input value={draft.phone ?? ""} onChange={(event) => update("phone", event.target.value || null)} />
|
||||
</label>
|
||||
<label className="profile-settings-field">
|
||||
<span>Должность</span>
|
||||
<input value={draft.position ?? ""} onChange={(event) => update("position", event.target.value || null)} />
|
||||
</label>
|
||||
|
||||
<div className="profile-settings-divider" />
|
||||
|
||||
<label className="profile-settings-field">
|
||||
<span>Новый пароль</span>
|
||||
<input
|
||||
value={newPassword}
|
||||
type="password"
|
||||
placeholder="Минимум 8 символов"
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{message ? <p className="profile-settings-message">{message}</p> : null}
|
||||
|
||||
<div className="profile-settings-panel__foot">
|
||||
<Button
|
||||
variant="secondary"
|
||||
surface="modal"
|
||||
type="button"
|
||||
icon={<KeyRound size={16} />}
|
||||
disabled={savingPassword || !newPassword}
|
||||
onClick={() => void handleSavePassword()}
|
||||
>
|
||||
{savingPassword ? "Обновляем" : "Сменить пароль"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
surface="modal"
|
||||
type="button"
|
||||
icon={<Save size={16} />}
|
||||
disabled={savingProfile || uploading}
|
||||
onClick={() => void handleSaveProfile()}
|
||||
>
|
||||
{savingProfile ? "Сохраняем" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Activity, Bot, Boxes, ChartNoAxesColumnIncreasing, KeyRound, Network, Sparkles } from "lucide-react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { DEFAULT_AMBIENT_MEDIA, resolveAmbientMedia } from "../../entities/service/media";
|
||||
import type { LauncherServiceView } from "../../entities/service/types";
|
||||
import { cn } from "../../shared/lib/cn";
|
||||
import { ServiceStatusBadge } from "../../shared/ui/StatusBadge";
|
||||
|
||||
export function ServiceRail({
|
||||
services,
|
||||
|
|
@ -12,35 +12,62 @@ export function ServiceRail({
|
|||
selectedServiceId?: string;
|
||||
onSelect: (serviceId: string) => void;
|
||||
}) {
|
||||
const selectedService = services.find((service) => service.id === selectedServiceId);
|
||||
const railMedia = resolveAmbientMedia(selectedService);
|
||||
|
||||
return (
|
||||
<div className="service-rail" aria-label="Доступные сервисы">
|
||||
{services.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
className={cn("service-tile", selectedServiceId === service.id && "service-tile--active")}
|
||||
onClick={() => onSelect(service.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="service-tile__media" style={{ "--tile-accent": service.accentColor ?? "#B5FF5A" } as React.CSSProperties}>
|
||||
<ServiceIcon slug={service.slug} />
|
||||
</span>
|
||||
<span className="service-tile__content">
|
||||
<strong>{service.title}</strong>
|
||||
<small>{service.subtitle}</small>
|
||||
</span>
|
||||
<ServiceStatusBadge status={service.status} />
|
||||
</button>
|
||||
))}
|
||||
<RailMedia className="service-rail__backdrop-media" src={railMedia.src} kind={railMedia.kind} />
|
||||
<div className="service-rail__glass" />
|
||||
<div className="service-rail__scroll">
|
||||
<div className="service-rail__track">
|
||||
{services.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
className={cn("service-tile", selectedServiceId === service.id && "service-tile--active")}
|
||||
onClick={() => onSelect(service.id)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="service-tile__media"
|
||||
style={{ "--tile-accent": service.accentColor ?? "#B5FF5A" } as React.CSSProperties}
|
||||
>
|
||||
<RailMedia
|
||||
className="service-tile__media-asset"
|
||||
src={service.media.coverImage ?? service.media.ambientVideo ?? DEFAULT_AMBIENT_MEDIA}
|
||||
kind={service.media.coverKind ?? service.media.ambientKind}
|
||||
/>
|
||||
</span>
|
||||
<span className="service-tile__content">
|
||||
<strong>{service.title}</strong>
|
||||
</span>
|
||||
<span className="service-tile__arrow" aria-hidden="true">
|
||||
<ChevronRight size={18} strokeWidth={2.1} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceIcon({ slug }: { slug: string }) {
|
||||
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
|
||||
if (slug.includes("1c")) return <Boxes size={18} />;
|
||||
if (slug.includes("tender")) return <KeyRound size={18} />;
|
||||
if (slug.includes("twin")) return <Activity size={18} />;
|
||||
if (slug.includes("digital-modules")) return <Sparkles size={18} />;
|
||||
if (slug.includes("internal")) return <Network size={18} />;
|
||||
return <Bot size={18} />;
|
||||
function RailMedia({
|
||||
src,
|
||||
kind,
|
||||
className,
|
||||
}: {
|
||||
src: string;
|
||||
kind?: LauncherServiceView["media"]["coverKind"];
|
||||
className: string;
|
||||
}) {
|
||||
if (kind === "video" || isVideoSource(src)) {
|
||||
return <video className={className} src={src} autoPlay loop muted playsInline />;
|
||||
}
|
||||
|
||||
return <img className={className} src={src} alt="" />;
|
||||
}
|
||||
|
||||
function isVideoSource(src: string) {
|
||||
return /\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,25 @@ import {
|
|||
Network,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { resolveAmbientMedia } from "../../entities/service/media";
|
||||
import type { LauncherServiceView } from "../../entities/service/types";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge";
|
||||
|
||||
const DEFAULT_STAGE_MEDIA = "/storage/default.gif";
|
||||
const stageActionAccentRgb = [247, 248, 244] as const;
|
||||
|
||||
export function ServiceStage({
|
||||
service,
|
||||
hasServices,
|
||||
onLaunch,
|
||||
onSelectPrevious,
|
||||
onSelectNext,
|
||||
}: {
|
||||
service?: LauncherServiceView;
|
||||
hasServices: boolean;
|
||||
onLaunch: (service: LauncherServiceView) => void;
|
||||
onSelectPrevious: () => void;
|
||||
onSelectNext: () => void;
|
||||
}) {
|
||||
if (!hasServices) {
|
||||
return (
|
||||
|
|
@ -42,11 +47,12 @@ export function ServiceStage({
|
|||
const style = {
|
||||
"--service-accent": service?.accentColor ?? "#C3FF66",
|
||||
} as React.CSSProperties;
|
||||
const ambientMedia = resolveAmbientMedia(service);
|
||||
const disabledReason = service
|
||||
? service.status === "maintenance"
|
||||
? "Сервис временно недоступен"
|
||||
: service.userAccess === "denied"
|
||||
? "Доступ не выдан"
|
||||
? "Нет доступа"
|
||||
: service.effectiveAccess.openEnabled
|
||||
? null
|
||||
: "Открытие заблокировано"
|
||||
|
|
@ -56,25 +62,7 @@ export function ServiceStage({
|
|||
<section className="service-stage" style={style}>
|
||||
<div className="stage-video-shell">
|
||||
<div className="stage-video-stream" aria-hidden="true">
|
||||
{service?.media.ambientVideo ? (
|
||||
<StageMedia className="stage-video-gif" src={service.media.ambientVideo} kind={service.media.ambientKind} />
|
||||
) : (
|
||||
<img className="stage-video-gif" src={DEFAULT_STAGE_MEDIA} alt="" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stage-video-topline">
|
||||
<button className="stage-round-button" type="button" aria-label="Назад">
|
||||
<ChevronLeft size={17} />
|
||||
</button>
|
||||
<span>{service?.title ?? "Витрина NODE.DC"}</span>
|
||||
</div>
|
||||
|
||||
<div className="stage-side-controls" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} />
|
||||
</div>
|
||||
|
||||
{service ? (
|
||||
|
|
@ -104,26 +92,24 @@ export function ServiceStage({
|
|||
|
||||
<div className="stage-description-card__copy">
|
||||
<h1>{service.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__description">
|
||||
<RichDescription text={service.fullDescription ?? service.description} />
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__chips">
|
||||
<ServiceStatusBadge status={service.status} />
|
||||
<StatusBadge
|
||||
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
|
||||
tone={service.userAccess === "allowed" ? "green" : "red"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__reason">
|
||||
<span>Почему видно</span>
|
||||
<strong>{service.effectiveAccess.reason}</strong>
|
||||
</div>
|
||||
|
||||
<div className="stage-description-card__actions">
|
||||
<div className="stage-description-card__footer">
|
||||
<div className="stage-description-card__chips">
|
||||
<ServiceStatusBadge status={service.status} />
|
||||
<StatusBadge
|
||||
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
|
||||
tone={service.userAccess === "allowed" ? "green" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
variant="accent"
|
||||
accentRgb={stageActionAccentRgb}
|
||||
icon={
|
||||
service.status === "maintenance" ? (
|
||||
<Wrench size={16} />
|
||||
|
|
@ -138,25 +124,21 @@ export function ServiceStage({
|
|||
>
|
||||
{disabledReason ?? "Открыть"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={<ChevronRight size={16} />}
|
||||
onClick={() => onLaunch(service)}
|
||||
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
|
||||
>
|
||||
Перейти
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="stage-empty-title" aria-label="NodeDC витрина модулей">
|
||||
<span>NODE.DC</span>
|
||||
<strong>Витрина модулей</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stage-video-controls" aria-hidden="true">
|
||||
<button type="button" tabIndex={-1}>
|
||||
<div className="stage-video-controls">
|
||||
<button type="button" aria-label="Предыдущий сервис" onClick={onSelectPrevious}>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
<button type="button" tabIndex={-1}>
|
||||
<button type="button" aria-label="Следующий сервис" onClick={onSelectNext}>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Inbox } from "lucide-react";
|
|||
import type { Client } from "../../entities/client/types";
|
||||
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
||||
import { initials } from "../../shared/lib/format";
|
||||
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
||||
|
||||
export function TopBar({
|
||||
me,
|
||||
|
|
@ -12,8 +13,10 @@ export function TopBar({
|
|||
adminOpen,
|
||||
onProfileChange,
|
||||
onClientChange,
|
||||
onOpenAdmin,
|
||||
onToggleAdmin,
|
||||
onOpenShowcase,
|
||||
onOpenProfileSettings,
|
||||
onLogout,
|
||||
}: {
|
||||
me: MeResponse;
|
||||
clients: Client[];
|
||||
|
|
@ -23,13 +26,25 @@ export function TopBar({
|
|||
adminOpen: boolean;
|
||||
onProfileChange: (userId: string) => void;
|
||||
onClientChange: (clientId: string) => void;
|
||||
onOpenAdmin: () => void;
|
||||
onToggleAdmin: () => void;
|
||||
onOpenShowcase: () => void;
|
||||
onOpenProfileSettings: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||
const activeClient = availableClients.find((client) => client.id === activeClientId);
|
||||
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
|
||||
const clientOptions = availableClients.map((client) => ({
|
||||
value: client.id,
|
||||
label: client.name,
|
||||
description: client.legalName ?? undefined,
|
||||
}));
|
||||
const profileSelectOptions = profileOptions.map((profile) => ({
|
||||
value: profile.userId,
|
||||
label: profile.label,
|
||||
description: profile.description,
|
||||
}));
|
||||
|
||||
return (
|
||||
<header className="nodedc-expanded-toolbar-shell">
|
||||
|
|
@ -42,35 +57,56 @@ export function TopBar({
|
|||
</div>
|
||||
|
||||
<div className="nodedc-expanded-toolbar-center">
|
||||
<label className="nodedc-expanded-workspace-button" title={activeClient?.name ?? "Клиент"}>
|
||||
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
|
||||
<select value={activeClientId} onChange={(event) => onClientChange(event.target.value)} aria-label="Выбрать клиента">
|
||||
{availableClients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<NodeDcSelect
|
||||
value={activeClientId}
|
||||
options={clientOptions}
|
||||
label="Выбрать клиента"
|
||||
searchable
|
||||
minMenuWidth={248}
|
||||
onChange={(clientId) => onClientChange(clientId)}
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-workspace-button"
|
||||
title={activeClient?.name ?? "Клиент"}
|
||||
type="button"
|
||||
aria-label="Выбрать клиента"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
|
||||
<span>Витрина</span>
|
||||
</button>
|
||||
|
||||
<label className="nodedc-expanded-nav-button nodedc-expanded-select-button" data-active="false">
|
||||
<span>{activeProfile?.label ?? me.user.name}</span>
|
||||
<select value={activeProfileId} onChange={(event) => onProfileChange(event.target.value)} aria-label="Выбрать профиль">
|
||||
{profileOptions.map((profile) => (
|
||||
<option key={profile.userId} value={profile.userId}>
|
||||
{profile.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<NodeDcSelect
|
||||
value={activeProfileId}
|
||||
options={profileSelectOptions}
|
||||
label="Выбрать профиль доступа"
|
||||
minMenuWidth={236}
|
||||
onChange={(userId) => onProfileChange(userId)}
|
||||
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-nav-button nodedc-expanded-select-button"
|
||||
type="button"
|
||||
data-active="false"
|
||||
aria-label="Выбрать профиль доступа"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{selectedOption?.label ?? activeProfile?.label ?? me.user.name}</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{me.permissions.canOpenAdmin ? (
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onOpenAdmin}>
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onToggleAdmin}>
|
||||
<span>Администрирование</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
|
@ -78,19 +114,45 @@ export function TopBar({
|
|||
</div>
|
||||
|
||||
<div className="nodedc-expanded-toolbar-right">
|
||||
<div className="nodedc-expanded-user-group" title={`${me.user.name} · ${me.user.email}`}>
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||
<span>Профиль</span>
|
||||
</button>
|
||||
<button className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" type="button" data-active="false" aria-label="Уведомления">
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<Inbox size={20} strokeWidth={1.7} />
|
||||
</span>
|
||||
</button>
|
||||
<button className="nodedc-expanded-user-avatar-button" type="button" aria-label="Профиль пользователя">
|
||||
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<NodeDcProfileMenu
|
||||
user={me.user}
|
||||
onSettings={onOpenProfileSettings}
|
||||
onLogout={onLogout}
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<div
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-user-group"
|
||||
title={`${me.user.name} · ${me.user.email}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Профиль пользователя"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-expanded-nav-button" data-active="false">
|
||||
<span>Профиль</span>
|
||||
</span>
|
||||
<span className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" data-active="false" aria-hidden="true">
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<Inbox size={20} strokeWidth={1.7} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
|
||||
{me.user.avatarUrl ? (
|
||||
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
|
||||
) : (
|
||||
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||