diff --git a/design.md b/design.md index ad58553..f4d2dda 100644 --- a/design.md +++ b/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. Пользовательская витрина diff --git a/package-lock.json b/package-lock.json index de03176..a9a6984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "nodedc-launcher", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^0.468.0", "react": "^19.1.1", "react-dom": "^19.1.1" @@ -304,6 +307,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -2047,6 +2103,12 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 2ced347..62fa990 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "test": "vitest run" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^0.468.0", "react": "^19.1.1", "react-dom": "^19.1.1" diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 078cc44..34fbfe4 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -236,7 +236,7 @@ "authentikApplicationSlug": "nodedc", "authentikGroupName": "service-nodedc", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T15:29:56.933Z", + "updatedAt": "2026-05-01T17:59:10.713Z", "coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg", "coverMediaKind": "image", "coverMediaSource": "file", @@ -262,7 +262,11 @@ "authentikApplicationSlug": "task-manager", "authentikGroupName": "service-task-manager", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T17:59:10.713Z", + "coverImageUrl": "/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png", + "coverMediaKind": "image", + "coverMediaSource": "file", + "coverMediaFileName": "1777652545129-cf547e17-NODEDC_TASK.png" }, { "id": "service_1c", @@ -276,11 +280,15 @@ "accentColor": "#8FD7FF", "fallbackGradient": "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)", "status": "maintenance", - "order": 30, + "order": 40, "authentikApplicationSlug": "1c-assistant", "authentikGroupName": "service-1c-assistant", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T17:59:10.713Z", + "coverImageUrl": "/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png", + "coverMediaKind": "image", + "coverMediaSource": "file", + "coverMediaFileName": "1777657277366-a9886413-LLM_MANAGER.png" }, { "id": "service_tender", @@ -294,15 +302,19 @@ "accentColor": "#FFD166", "fallbackGradient": "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)", "status": "active", - "order": 40, + "order": 30, "authentikApplicationSlug": "tender-agent", "authentikGroupName": "service-tender-agent", "createdAt": "2026-04-03T10:00:00Z", - "updatedAt": "2026-05-01T15:37:01.591Z", - "coverImageUrl": "/storage/uploads/1777649809136-7935fb4d-2026-05-01-18.36.20.jpg", + "updatedAt": "2026-05-01T17:59:10.713Z", + "coverImageUrl": "/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg", "coverMediaKind": "image", "coverMediaSource": "file", - "coverMediaFileName": "1777649809136-7935fb4d-2026-05-01-18.36.20.jpg" + "coverMediaFileName": "1777652810531-1f17b7ed-LAP_PURP4k.jpg", + "ambientVideoUrl": "/storage/uploads/1777652817155-199c5e6c-090aff247929335.69e6521716ea9.gif", + "ambientMediaKind": "gif", + "ambientMediaSource": "file", + "ambientMediaFileName": "1777652817155-199c5e6c-090aff247929335.69e6521716ea9.gif" }, { "id": "service_digital_twin", @@ -320,7 +332,7 @@ "authentikApplicationSlug": "digital-twin", "authentikGroupName": "service-digital-twin", "createdAt": "2026-04-05T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T17:59:10.713Z" }, { "id": "service_dm", @@ -338,7 +350,7 @@ "authentikApplicationSlug": "digital-modules", "authentikGroupName": "service-digital-modules", "createdAt": "2026-04-10T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T17:59:10.713Z" }, { "id": "service_internal", @@ -356,7 +368,7 @@ "authentikApplicationSlug": "internal-tools", "authentikGroupName": "service-internal-tools", "createdAt": "2026-04-12T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T17:59:10.713Z" } ], "grants": [ diff --git a/public/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png b/public/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png new file mode 100644 index 0000000..ac2b2d5 Binary files /dev/null and b/public/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png differ diff --git a/public/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg b/public/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg new file mode 100644 index 0000000..d21d1c1 Binary files /dev/null and b/public/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg differ diff --git a/public/storage/uploads/1777652817155-199c5e6c-090aff247929335.69e6521716ea9.gif b/public/storage/uploads/1777652817155-199c5e6c-090aff247929335.69e6521716ea9.gif new file mode 100644 index 0000000..07f9b5b Binary files /dev/null and b/public/storage/uploads/1777652817155-199c5e6c-090aff247929335.69e6521716ea9.gif differ diff --git a/public/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png b/public/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png new file mode 100644 index 0000000..7b9aae5 Binary files /dev/null and b/public/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png differ diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index b24c34e..86935d9 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -92,6 +92,23 @@ export function LauncherApp() { setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId)); } + function handleStageStep(direction: "previous" | "next") { + if (!launcherServices.length) return; + + 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; + + return launcherServices[nextIndex]?.id; + }); + } + function handleCreateGrant(grant: Omit) { setData((current) => ({ ...current, @@ -183,6 +200,28 @@ export function LauncherApp() { })); } + function handleReorderServices(orderedServiceIds: string[]) { + setData((current) => { + const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10])); + const now = new Date().toISOString(); + + return { + ...current, + services: current.services.map((service) => { + const nextOrder = orderById.get(service.id); + + return nextOrder + ? { + ...service, + order: nextOrder, + updatedAt: now, + } + : service; + }), + }; + }); + } + function handleCreateService() { const createdAt = new Date().toISOString(); @@ -248,7 +287,13 @@ export function LauncherApp() { />
- 0} onLaunch={handleLaunch} /> + 0} + onLaunch={handleLaunch} + onSelectPrevious={() => handleStageStep("previous")} + onSelectNext={() => handleStageStep("next")} + /> {adminOpen && me.permissions.canOpenAdmin ? ( diff --git a/src/styles/globals.css b/src/styles/globals.css index 39185a2..7e11ef7 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -867,70 +867,137 @@ code { } .service-rail { + --service-rail-card-size: var(--launcher-rail-height); + --service-rail-gap: var(--service-rail-card-size); + --service-tile-joint-gap: 5px; position: absolute; z-index: 5; right: var(--launcher-page-pad); bottom: var(--launcher-rail-bottom); left: var(--launcher-page-pad); - display: flex; - align-items: center; - gap: 0.7rem; - overflow-x: auto; - padding: 0.7rem; + height: var(--launcher-rail-height); + overflow: hidden; border: 0; border-radius: var(--launcher-radius-card); - background: rgba(8, 8, 11, 0.86); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); + background: rgba(8, 8, 11, 0.72); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36); +} + +.service-rail__backdrop-media { + position: absolute; + inset: -10%; + width: 120%; + height: 120%; + object-fit: cover; + object-position: center; + opacity: 0.58; + filter: saturate(1.08) contrast(0.96); +} + +.service-rail__glass { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.105), rgba(255, 255, 255, 0.035)), + rgba(13, 13, 16, 0.46); + backdrop-filter: blur(28px) saturate(1.12); + -webkit-backdrop-filter: blur(28px) saturate(1.12); +} + +.service-rail__scroll { + position: relative; + z-index: 1; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + padding: 0 3rem; scrollbar-width: none; } -.service-rail::-webkit-scrollbar { +.service-rail__scroll::-webkit-scrollbar { display: none; } +.service-rail__track { + display: flex; + width: max-content; + min-width: 100%; + height: 100%; + align-items: stretch; + justify-content: space-evenly; + gap: var(--service-rail-gap); +} + .service-tile { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - justify-items: center; - align-items: center; - gap: 0.45rem; - width: 7.8rem; - min-width: 7.8rem; - height: 7.8rem; - padding: 0.7rem; + position: relative; + display: flex; + gap: var(--service-tile-joint-gap); + width: calc((var(--service-rail-card-size) * 2) + var(--service-tile-joint-gap)); + min-width: calc((var(--service-rail-card-size) * 2) + var(--service-tile-joint-gap)); + height: 100%; + align-items: stretch; + padding: 0; border: 0; border-radius: 1rem; - background: rgb(var(--nodedc-card-passive-rgb)); + background: transparent; color: var(--text-primary); - text-align: center; + text-align: left; } .service-tile:hover { - background: rgba(255, 255, 255, 0.12); + color: var(--text-primary); } -.service-tile--active { +.service-tile:hover .service-tile__content { + background: rgba(255, 255, 255, 0.17); +} + +.service-tile--active .service-tile__content { + background: + linear-gradient(90deg, rgba(var(--nodedc-accent-rgb), 0.28), rgba(255, 255, 255, 0.14)), + rgba(255, 255, 255, 0.1); +} + +.service-tile--active .service-tile__media { + box-shadow: inset 0 0 0 2px rgba(var(--nodedc-accent-rgb), 0.8); +} + +.service-tile--active .service-tile__arrow { background: rgb(var(--nodedc-card-active-rgb)); color: rgb(var(--nodedc-on-accent-rgb)); } -.service-tile--active small, -.service-tile--active .status-badge { - color: rgba(11, 17, 23, 0.72); -} - .service-tile__media { - display: grid; - width: 2.65rem; - height: 2.65rem; - place-items: center; - border-radius: 0.95rem; + position: relative; + display: block; + width: var(--service-rail-card-size); + height: var(--service-rail-card-size); + flex: 0 0 var(--service-rail-card-size); + overflow: hidden; + border-radius: 1rem; background: color-mix(in srgb, var(--tile-accent) 36%, rgba(255, 255, 255, 0.12)); } +.service-tile__media-asset { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + .service-tile__content { + display: grid; + width: var(--service-rail-card-size); + height: 100%; min-width: 0; + align-content: center; + gap: 0.35rem; + overflow: hidden; + padding: 0.72rem 0.82rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.115); + backdrop-filter: blur(24px) saturate(1.1); + -webkit-backdrop-filter: blur(24px) saturate(1.1); } .service-tile__content strong, @@ -942,15 +1009,49 @@ code { } .service-tile__content strong { - min-height: 2.15rem; - font-size: 0.84rem; + font-size: 0.9rem; line-height: 1.15; -webkit-line-clamp: 2; } .service-tile__content small { - display: none; - color: var(--text-muted); + color: rgba(255, 255, 255, 0.68); + font-size: 0.68rem; + line-height: 1.2; + -webkit-line-clamp: 2; +} + +.service-tile__content .status-badge { + min-height: 1.35rem; + padding: 0 0.48rem; + font-size: 0.66rem; +} + +.service-tile__arrow { + position: absolute; + z-index: 2; + top: 50%; + right: -1.1rem; + display: grid; + width: 2.3rem; + height: 2.3rem; + place-items: center; + border-radius: var(--launcher-radius-circle); + background: rgba(247, 248, 244, 0.94); + color: rgba(8, 8, 10, 0.96); + transform: translateY(-50%); +} + +.service-tile:hover .service-tile__arrow { + filter: brightness(1.08); +} + +.service-tile:focus-visible { + outline: none; +} + +.service-tile:focus-visible .service-tile__arrow { + box-shadow: 0 0 0 3px rgba(var(--nodedc-accent-rgb), 0.42); } .button { @@ -1220,13 +1321,13 @@ code { border: 0; border-radius: var(--launcher-radius-circle); background: rgba(255, 255, 255, 0.04); - color: var(--text-primary); + color: rgba(255, 255, 255, 0.66); padding: var(--admin-control-inset) 0.9rem var(--admin-control-inset) calc(var(--admin-control-inset) + var(--admin-control-ring) + 0.86rem); font-size: 0.91rem; font-weight: 570; text-align: left; - opacity: 0.72; + opacity: 0.66; transition: background 160ms ease, color 160ms ease, @@ -1242,8 +1343,8 @@ code { left: var(--admin-control-inset); transform: translateY(-50%); border: 0; - background: rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.74); + background: rgba(255, 255, 255, 0.045); + color: rgba(255, 255, 255, 0.58); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 8px 20px rgba(0, 0, 0, 0.16); @@ -1267,14 +1368,14 @@ code { .admin-panel-nav-item--active { background: rgba(255, 255, 255, 0.115); color: var(--text-primary); - opacity: 0.82; + opacity: 1; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.048), 0 10px 22px rgba(0, 0, 0, 0.12); } .admin-panel-nav-item--active .admin-panel-nav-item__icon { - background: rgb(var(--nodedc-card-active-rgb)); + background: rgba(247, 248, 244, 0.96); color: rgb(var(--nodedc-on-accent-rgb)); } @@ -1596,15 +1697,15 @@ code { } .services-admin-table th:nth-child(2) { - width: 15%; + width: 13%; } .services-admin-table th:nth-child(3) { - width: 11%; + width: 12%; } .services-admin-table th:nth-child(4) { - width: 24%; + width: 25%; } .services-admin-table th:nth-child(5) { @@ -1612,11 +1713,33 @@ code { } .services-admin-table th:nth-child(6) { - width: 7%; + width: 3.4rem; } .services-admin-table th:nth-child(7) { - width: 3.9rem; + width: 3.1rem; +} + +.services-admin-table tbody tr { + transition: + transform 220ms cubic-bezier(0.2, 0.82, 0.2, 1), + opacity 160ms ease, + background 160ms ease; +} + +.services-admin-table tbody tr:has(.services-admin-table__drag-handle:hover), +.services-admin-table tbody tr:has(.services-admin-table__drag-handle:focus-visible) { + background: rgba(255, 255, 255, 0.035); +} + +.services-admin-table__row--dragging { + position: relative; + z-index: 6; + opacity: 1; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)), + rgba(22, 22, 24, 0.94); + box-shadow: 0 1.2rem 2.6rem rgba(0, 0, 0, 0.36); } .services-admin-table__service { @@ -1653,10 +1776,142 @@ code { font-size: 0.72rem; } -.admin-table-input--order { +.service-status-dropdown { + width: 7.45rem; + min-width: 7.45rem; +} + +.service-status-trigger { + display: inline-flex; + width: 7.45rem; + min-height: 2.08rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.085); + color: rgba(255, 255, 255, 0.84); + padding: 0 0.7rem; + font-size: 0.72rem; + font-weight: 850; +} + +.service-status-trigger[data-status="active"] { + background: rgba(181, 255, 90, 0.075); + color: rgba(226, 255, 190, 0.94); +} + +.service-status-trigger[data-status="maintenance"] { + background: rgba(246, 201, 95, 0.075); + color: rgba(255, 232, 178, 0.94); +} + +.service-status-trigger[data-status="hidden"] { + background: rgba(210, 197, 255, 0.07); + color: rgba(232, 225, 255, 0.92); +} + +.service-status-trigger[data-status="disabled"] { + background: rgba(255, 120, 120, 0.07); + color: rgba(255, 216, 216, 0.92); +} + +.service-status-trigger span { + width: 100%; text-align: center; } +.service-status-trigger:hover, +.service-status-trigger[aria-expanded="true"] { + filter: brightness(1.12); + color: var(--text-primary); +} + +.service-status-menu { + display: grid; + gap: 0.18rem; + min-width: 9.8rem; +} + +.service-status-menu__option { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + min-height: 2.45rem; + align-items: center; + border: 0; + border-radius: 0.92rem; + background: transparent; + color: rgba(255, 255, 255, 0.68); + padding: 0 0.78rem; + text-align: left; + font-size: 0.8rem; + font-weight: 780; +} + +.service-status-menu__option span { + text-align: left; +} + +.service-status-menu__mark { + display: block; + width: 0.45rem; + height: 0.45rem; + border-radius: var(--launcher-radius-circle); + background: transparent; +} + +.service-status-menu__option:hover, +.service-status-menu__option:focus-visible { + background: rgba(255, 255, 255, 0.075); + color: var(--text-primary); + outline: none; +} + +.service-status-menu__option[data-selected="true"] { + background: rgba(255, 255, 255, 0.11); + color: var(--text-primary); +} + +.service-status-menu__option[data-selected="true"] .service-status-menu__mark { + background: rgba(247, 248, 244, 0.96); +} + +.service-status-menu__option[data-status="active"][data-selected="true"] { + background: rgba(181, 255, 90, 0.065); + color: rgba(226, 255, 190, 0.96); +} + +.service-status-menu__option[data-status="maintenance"][data-selected="true"] { + background: rgba(246, 201, 95, 0.065); + color: rgba(255, 232, 178, 0.96); +} + +.service-status-menu__option[data-status="hidden"][data-selected="true"] { + background: rgba(210, 197, 255, 0.06); + color: rgba(232, 225, 255, 0.94); +} + +.service-status-menu__option[data-status="disabled"][data-selected="true"] { + background: rgba(255, 120, 120, 0.06); + color: rgba(255, 216, 216, 0.94); +} + +.service-status-menu__option[data-status="active"][data-selected="true"] .service-status-menu__mark { + background: rgba(181, 255, 90, 0.34); +} + +.service-status-menu__option[data-status="maintenance"][data-selected="true"] .service-status-menu__mark { + background: rgba(246, 201, 95, 0.34); +} + +.service-status-menu__option[data-status="hidden"][data-selected="true"] .service-status-menu__mark { + background: rgba(210, 197, 255, 0.32); +} + +.service-status-menu__option[data-status="disabled"][data-selected="true"] .service-status-menu__mark { + background: rgba(255, 120, 120, 0.32); +} + .admin-table-select { appearance: none; border-radius: 999px; @@ -1675,6 +1930,34 @@ code { margin-left: auto; } +.services-admin-table__drag-cell { + text-align: right; +} + +.services-admin-table__drag-handle { + display: inline-grid; + width: 2.35rem; + height: 2.35rem; + place-items: center; + border: 1px solid transparent; + border-radius: var(--launcher-radius-circle); + background: transparent; + color: rgba(255, 255, 255, 0.56); + cursor: grab; +} + +.services-admin-table__drag-handle:hover, +.services-admin-table__drag-handle:focus-visible { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.07); + color: var(--text-primary); + outline: none; +} + +.services-admin-table__drag-handle:active { + cursor: grabbing; +} + .service-content-modal-layer { position: fixed; z-index: 60; @@ -2030,10 +2313,25 @@ code { line-height: 1.5; } -.portal-dropdown { +.portal-dropdown, +.nodedc-dropdown-surface { position: fixed; z-index: 420; padding: 0.65rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.026)), + rgba(13, 13, 16, 0.9); + box-shadow: 0 24px 88px rgba(0, 0, 0, 0.58); + backdrop-filter: blur(26px) saturate(1.12); + -webkit-backdrop-filter: blur(26px) saturate(1.12); +} + +.nodedc-dropdown-option { + min-height: 2.45rem; + border: 0; + border-radius: 0.92rem; + background: transparent; + color: rgba(255, 255, 255, 0.68); } @media (max-width: 1120px) { @@ -2160,21 +2458,32 @@ code { } .service-rail { + --service-rail-card-size: var(--launcher-rail-height); right: 0.65rem; bottom: 0.65rem; left: 0.65rem; - padding: 0.55rem; + } + + .service-rail__scroll { + padding: 0 1.8rem; + } + + .service-rail__track { + gap: calc(var(--service-rail-card-size) * 0.72); } .service-tile { - width: 6.9rem; - min-width: 6.9rem; - height: 6.9rem; + width: calc((var(--service-rail-card-size) * 2) + var(--service-tile-joint-gap)); + min-width: calc((var(--service-rail-card-size) * 2) + var(--service-tile-joint-gap)); } - .service-tile__media { - width: 2.35rem; - height: 2.35rem; + .service-tile__content { + width: var(--service-rail-card-size); + padding: 0.62rem 0.68rem; + } + + .service-tile__content small { + display: none; } .admin-backdrop { diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 0a94600..139f9d1 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -1,4 +1,17 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DraggableAttributes, + type DraggableSyntheticListeners, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Building2, ChevronDown, @@ -6,6 +19,7 @@ import { DatabaseZap, Edit3, Globe2, + GripVertical, HardDrive, Image as ImageIcon, KeyRound, @@ -43,6 +57,7 @@ import { cn } from "../../shared/lib/cn"; import { formatDate, formatDateTime } from "../../shared/lib/format"; import { Button, IconButton } from "../../shared/ui/Button"; import { GlassSurface } from "../../shared/ui/Glass"; +import { PortalDropdown } from "../../shared/ui/PortalDropdown"; import { ClientStatusBadge, ServiceStatusBadge, SyncStatusBadge, UserStatusBadge } from "../../shared/ui/StatusBadge"; type AdminSection = @@ -90,6 +105,7 @@ export function AdminOverlay({ onCreateInvite, onRetrySync, onUpdateService, + onReorderServices, onCreateService, onDeleteService, }: { @@ -103,6 +119,7 @@ export function AdminOverlay({ onCreateInvite: (invite: Pick) => void; onRetrySync: (syncId: string) => void; onUpdateService: (serviceId: string, patch: Partial) => void; + onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; }) { @@ -201,6 +218,7 @@ export function AdminOverlay({ @@ -409,17 +427,62 @@ const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv"; function ServicesSection({ data, onUpdateService, + onReorderServices, onCreateService, onDeleteService, }: { data: LauncherData; onUpdateService: (serviceId: string, patch: Partial) => void; + onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; }) { const [contentServiceId, setContentServiceId] = useState(null); + const [activeServiceId, setActiveServiceId] = useState(null); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const sortedServices = useMemo(() => data.services.slice().sort((a, b) => a.order - b.order), [data.services]); + const [orderedServiceIds, setOrderedServiceIds] = useState(() => sortedServices.map((service) => service.id)); + const displayedServices = useMemo(() => { + const servicesById = new Map(data.services.map((service) => [service.id, service])); + const orderedServices = orderedServiceIds.map((serviceId) => servicesById.get(serviceId)).filter(Boolean) as Service[]; + const missingServices = sortedServices.filter((service) => !orderedServiceIds.includes(service.id)); + + return [...orderedServices, ...missingServices]; + }, [data.services, orderedServiceIds, sortedServices]); const contentService = data.services.find((service) => service.id === contentServiceId) ?? null; + useEffect(() => { + if (!activeServiceId) { + setOrderedServiceIds(sortedServices.map((service) => service.id)); + } + }, [activeServiceId, sortedServices]); + + function handleDragStart(event: DragStartEvent) { + setActiveServiceId(String(event.active.id)); + } + + function handleDragEnd(event: DragEndEvent) { + const activeId = String(event.active.id); + const overId = event.over ? String(event.over.id) : null; + + setActiveServiceId(null); + + if (!overId || activeId === overId) return; + + const oldIndex = orderedServiceIds.indexOf(activeId); + const newIndex = orderedServiceIds.indexOf(overId); + + if (oldIndex === -1 || newIndex === -1) return; + + const nextIds = arrayMove(orderedServiceIds, oldIndex, newIndex); + setOrderedServiceIds(nextIds); + onReorderServices(nextIds); + } + + function handleDragCancel() { + setActiveServiceId(null); + } + return ( <> @@ -429,96 +492,40 @@ function ServicesSection({ - - - - - - - - - - - - - {data.services.map((service) => ( - - - - - - - - + +
СервисSlugСтатусURLAuthentikПорядок -
- onUpdateService(service.id, { title: event.target.value })} - aria-label={`Название сервиса ${service.title}`} - /> - onUpdateService(service.id, { subtitle: event.target.value || null })} - aria-label={`Подзаголовок сервиса ${service.title}`} - /> - - onUpdateService(service.id, { slug: event.target.value })} - aria-label={`Slug сервиса ${service.title}`} - /> - - - - onUpdateService(service.id, { url: event.target.value })} - aria-label={`URL сервиса ${service.title}`} - /> - - onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })} - aria-label={`Authentik slug сервиса ${service.title}`} - /> - - onUpdateService(service.id, { order: Number(event.target.value) || 0 })} - aria-label={`Порядок сервиса ${service.title}`} - /> - - setContentServiceId(service.id)} - > - - -
+ + + + + + + + + - ))} - -
СервисSlugСтатусURLAuthentik +
+ + service.id)} strategy={verticalListSortingStrategy}> + + {displayedServices.map((service) => ( + setContentServiceId(service.id)} + /> + ))} + + + +
{contentService ? ( @@ -539,6 +546,223 @@ function ServicesSection({ ); } +function ServiceTableColGroup() { + return ( + + + + + + + + + + ); +} + +function SortableServiceRow({ + service, + onUpdateService, + onOpenContent, +}: { + service: Service; + onUpdateService: (serviceId: string, patch: Partial) => void; + onOpenContent: () => void; +}) { + const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + + + + ); +} + +function ServiceTableCells({ + service, + onUpdateService, + onOpenContent, + dragAttributes, + dragListeners, + setDragHandleRef, +}: { + service: Service; + onUpdateService: (serviceId: string, patch: Partial) => void; + onOpenContent: () => void; + dragAttributes?: DraggableAttributes; + dragListeners?: DraggableSyntheticListeners; + setDragHandleRef?: (node: HTMLButtonElement | null) => void; +}) { + return ( + <> + + onUpdateService(service.id, { title: event.target.value })} + aria-label={`Название сервиса ${service.title}`} + /> + onUpdateService(service.id, { subtitle: event.target.value || null })} + aria-label={`Подзаголовок сервиса ${service.title}`} + /> + + + onUpdateService(service.id, { slug: event.target.value })} + aria-label={`Slug сервиса ${service.title}`} + /> + + + onUpdateService(service.id, { status })} + /> + + + onUpdateService(service.id, { url: event.target.value })} + aria-label={`URL сервиса ${service.title}`} + /> + + + onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })} + aria-label={`Authentik slug сервиса ${service.title}`} + /> + + + + + + + + + + + ); +} + +function ServiceStatusDropdown({ + value, + label, + onChange, +}: { + value: ServiceStatus; + label: string; + onChange: (status: ServiceStatus) => void; +}) { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState(); + const selectedOption = serviceStatusOptions.find((option) => option.value === value) ?? serviceStatusOptions[0]; + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + + if (target && (triggerRef.current?.contains(target) || target.closest("[data-service-status-menu='true']"))) { + return; + } + + setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + function toggleOpen() { + const rect = triggerRef.current?.getBoundingClientRect(); + + if (rect) { + setMenuStyle({ + top: rect.bottom + 8, + left: rect.left, + width: Math.max(rect.width, 156), + }); + } + + setOpen((current) => !current); + } + + return ( +
+ + + +
+ {serviceStatusOptions.map((option) => ( + + ))} +
+
+
+ ); +} + function ServiceContentModal({ service, onClose, diff --git a/src/widgets/service-rail/ServiceRail.tsx b/src/widgets/service-rail/ServiceRail.tsx index 6aea94e..d56259d 100644 --- a/src/widgets/service-rail/ServiceRail.tsx +++ b/src/widgets/service-rail/ServiceRail.tsx @@ -1,7 +1,8 @@ -import { Activity, Bot, Boxes, ChartNoAxesColumnIncreasing, KeyRound, Network, Sparkles } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import type { LauncherServiceView } from "../../entities/service/types"; import { cn } from "../../shared/lib/cn"; -import { ServiceStatusBadge } from "../../shared/ui/StatusBadge"; + +const DEFAULT_RAIL_MEDIA = "/storage/default.gif"; export function ServiceRail({ services, @@ -12,35 +13,63 @@ export function ServiceRail({ selectedServiceId?: string; onSelect: (serviceId: string) => void; }) { + const selectedService = services.find((service) => service.id === selectedServiceId) ?? services[0]; + const railMediaSrc = selectedService?.media.ambientVideo ?? selectedService?.media.coverImage ?? DEFAULT_RAIL_MEDIA; + const railMediaKind = selectedService?.media.ambientKind ?? selectedService?.media.coverKind; + return (
- {services.map((service) => ( - - ))} + +
+
+
+ {services.map((service) => ( + + ))} +
+
); } -function ServiceIcon({ slug }: { slug: string }) { - if (slug.includes("task")) return ; - if (slug.includes("1c")) return ; - if (slug.includes("tender")) return ; - if (slug.includes("twin")) return ; - if (slug.includes("digital-modules")) return ; - if (slug.includes("internal")) return ; - return ; +function RailMedia({ + src, + kind, + className, +}: { + src: string; + kind?: LauncherServiceView["media"]["coverKind"]; + className: string; +}) { + if (kind === "video" || isVideoSource(src)) { + return
) : null} -