feat: refine launcher admin and showcase UI

This commit is contained in:
DCCONSTRUCTIONS 2026-05-01 21:00:51 +03:00
parent e8c6e76885
commit db0eabe7d7
13 changed files with 897 additions and 189 deletions

View File

@ -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. Пользовательская витрина

62
package-lock.json generated
View File

@ -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",

View File

@ -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"

View File

@ -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": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -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<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) {
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() {
/>
<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}
@ -261,6 +306,7 @@ export function LauncherApp() {
onCreateInvite={handleCreateInvite}
onRetrySync={handleRetrySync}
onUpdateService={handleUpdateService}
onReorderServices={handleReorderServices}
onCreateService={handleCreateService}
onDeleteService={handleDeleteService}
/>

View File

@ -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 {

View File

@ -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<Invite, "clientId" | "email" | "role">) => void;
onRetrySync: (syncId: string) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
onDeleteService: (serviceId: string) => void;
}) {
@ -201,6 +218,7 @@ export function AdminOverlay({
<ServicesSection
data={data}
onUpdateService={onUpdateService}
onReorderServices={onReorderServices}
onCreateService={onCreateService}
onDeleteService={onDeleteService}
/>
@ -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<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
onDeleteService: (serviceId: string) => void;
}) {
const [contentServiceId, setContentServiceId] = useState<string | null>(null);
const [activeServiceId, setActiveServiceId] = useState<string | null>(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<string[]>(() => 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 (
<>
<GlassSurface className="table-shell services-table-shell">
@ -429,7 +492,15 @@ function ServicesSection({
<Plus size={17} />
</IconButton>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<table className="services-admin-table">
<ServiceTableColGroup />
<thead>
<tr>
<th>Сервис</th>
@ -437,13 +508,104 @@ function ServicesSection({
<th>Статус</th>
<th>URL</th>
<th>Authentik</th>
<th>Порядок</th>
<th aria-label="Редактирование" />
<th aria-label="Порядок" />
</tr>
</thead>
<SortableContext items={displayedServices.map((service) => service.id)} strategy={verticalListSortingStrategy}>
<tbody>
{data.services.map((service) => (
<tr key={service.id}>
{displayedServices.map((service) => (
<SortableServiceRow
key={service.id}
service={service}
onUpdateService={onUpdateService}
onOpenContent={() => setContentServiceId(service.id)}
/>
))}
</tbody>
</SortableContext>
</table>
</DndContext>
</GlassSurface>
{contentService ? (
<ServiceContentModal
service={contentService}
onClose={() => setContentServiceId(null)}
onSave={(patch) => {
onUpdateService(contentService.id, patch);
setContentServiceId(null);
}}
onDelete={() => {
onDeleteService(contentService.id);
setContentServiceId(null);
}}
/>
) : null}
</>
);
}
function ServiceTableColGroup() {
return (
<colgroup>
<col style={{ width: "24%" }} />
<col style={{ width: "13%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "25%" }} />
<col style={{ width: "15%" }} />
<col style={{ width: "3.4rem" }} />
<col style={{ width: "3.1rem" }} />
</colgroup>
);
}
function SortableServiceRow({
service,
onUpdateService,
onOpenContent,
}: {
service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onOpenContent: () => void;
}) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<tr ref={setNodeRef} style={style} className={cn("services-admin-table__row", isDragging && "services-admin-table__row--dragging")}>
<ServiceTableCells
service={service}
onUpdateService={onUpdateService}
onOpenContent={onOpenContent}
dragAttributes={attributes}
dragListeners={listeners}
setDragHandleRef={setActivatorNodeRef}
/>
</tr>
);
}
function ServiceTableCells({
service,
onUpdateService,
onOpenContent,
dragAttributes,
dragListeners,
setDragHandleRef,
}: {
service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onOpenContent: () => void;
dragAttributes?: DraggableAttributes;
dragListeners?: DraggableSyntheticListeners;
setDragHandleRef?: (node: HTMLButtonElement | null) => void;
}) {
return (
<>
<td className="services-admin-table__service">
<input
className="admin-table-input admin-table-input--strong"
@ -467,18 +629,11 @@ function ServicesSection({
/>
</td>
<td>
<select
className="admin-table-input admin-table-select"
<ServiceStatusDropdown
value={service.status}
onChange={(event) => onUpdateService(service.id, { status: event.target.value as ServiceStatus })}
aria-label={`Статус сервиса ${service.title}`}
>
{serviceStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
label={`Статус сервиса ${service.title}`}
onChange={(status) => onUpdateService(service.id, { status })}
/>
</td>
<td>
<input
@ -496,49 +651,118 @@ function ServicesSection({
aria-label={`Authentik slug сервиса ${service.title}`}
/>
</td>
<td>
<input
className="admin-table-input admin-table-input--order"
type="number"
value={service.order}
onChange={(event) => onUpdateService(service.id, { order: Number(event.target.value) || 0 })}
aria-label={`Порядок сервиса ${service.title}`}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Контент витрины ${service.title}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setContentServiceId(service.id)}
>
<IconButton label={`Контент витрины ${service.title}`} className="admin-circle-action services-admin-table__edit" type="button" onClick={onOpenContent}>
<Edit3 size={15} />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
{contentService ? (
<ServiceContentModal
service={contentService}
onClose={() => setContentServiceId(null)}
onSave={(patch) => {
onUpdateService(contentService.id, patch);
setContentServiceId(null);
}}
onDelete={() => {
onDeleteService(contentService.id);
setContentServiceId(null);
}}
/>
) : null}
<td className="services-admin-table__drag-cell">
<button
ref={setDragHandleRef}
className="services-admin-table__drag-handle"
type="button"
aria-label={`Перетащить сервис ${service.title}`}
{...dragAttributes}
{...dragListeners}
>
<GripVertical size={16} strokeWidth={1.9} />
</button>
</td>
</>
);
}
function ServiceStatusDropdown({
value,
label,
onChange,
}: {
value: ServiceStatus;
label: string;
onChange: (status: ServiceStatus) => void;
}) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
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 (
<div className="service-status-dropdown">
<button
ref={triggerRef}
className="service-status-trigger"
data-status={value}
type="button"
aria-label={label}
aria-expanded={open}
onClick={toggleOpen}
>
<span>{selectedOption.label}</span>
</button>
<PortalDropdown open={open} style={menuStyle}>
<div className="service-status-menu" data-service-status-menu="true">
{serviceStatusOptions.map((option) => (
<button
key={option.value}
className="service-status-menu__option"
data-selected={option.value === value}
data-status={option.value}
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span className="service-status-menu__mark" aria-hidden="true" />
<span>{option.label}</span>
</button>
))}
</div>
</PortalDropdown>
</div>
);
}
function ServiceContentModal({
service,
onClose,

View File

@ -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,8 +13,16 @@ 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 (
<div className="service-rail" aria-label="Доступные сервисы">
<RailMedia className="service-rail__backdrop-media" src={railMediaSrc} kind={railMediaKind} />
<div className="service-rail__glass" />
<div className="service-rail__scroll">
<div className="service-rail__track">
{services.map((service) => (
<button
key={service.id}
@ -21,26 +30,46 @@ export function ServiceRail({
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
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_RAIL_MEDIA}
kind={service.media.coverKind ?? service.media.ambientKind}
/>
</span>
<span className="service-tile__content">
<strong>{service.title}</strong>
<small>{service.subtitle}</small>
</span>
<ServiceStatusBadge status={service.status} />
<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);
}

View File

@ -22,10 +22,14 @@ export function ServiceStage({
service,
hasServices,
onLaunch,
onSelectPrevious,
onSelectNext,
}: {
service?: LauncherServiceView;
hasServices: boolean;
onLaunch: (service: LauncherServiceView) => void;
onSelectPrevious: () => void;
onSelectNext: () => void;
}) {
if (!hasServices) {
return (
@ -152,11 +156,11 @@ export function ServiceStage({
</div>
) : null}
<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>