feat: refine launcher admin and showcase UI
This commit is contained in:
parent
e8c6e76885
commit
db0eabe7d7
21
design.md
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. Пользовательская витрина
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,96 +492,40 @@ function ServicesSection({
|
|||
<Plus size={17} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<table className="services-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Сервис</th>
|
||||
<th>Slug</th>
|
||||
<th>Статус</th>
|
||||
<th>URL</th>
|
||||
<th>Authentik</th>
|
||||
<th>Порядок</th>
|
||||
<th aria-label="Редактирование" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.services.map((service) => (
|
||||
<tr key={service.id}>
|
||||
<td className="services-admin-table__service">
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={service.title}
|
||||
onChange={(event) => onUpdateService(service.id, { title: event.target.value })}
|
||||
aria-label={`Название сервиса ${service.title}`}
|
||||
/>
|
||||
<input
|
||||
className="admin-table-input admin-table-input--muted"
|
||||
value={service.subtitle ?? ""}
|
||||
onChange={(event) => onUpdateService(service.id, { subtitle: event.target.value || null })}
|
||||
aria-label={`Подзаголовок сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={service.slug}
|
||||
onChange={(event) => onUpdateService(service.id, { slug: event.target.value })}
|
||||
aria-label={`Slug сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="admin-table-input admin-table-select"
|
||||
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>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={service.url}
|
||||
onChange={(event) => onUpdateService(service.id, { url: event.target.value })}
|
||||
aria-label={`URL сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={service.authentikApplicationSlug ?? ""}
|
||||
onChange={(event) => onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })}
|
||||
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)}
|
||||
>
|
||||
<Edit3 size={15} />
|
||||
</IconButton>
|
||||
</td>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<table className="services-admin-table">
|
||||
<ServiceTableColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Сервис</th>
|
||||
<th>Slug</th>
|
||||
<th>Статус</th>
|
||||
<th>URL</th>
|
||||
<th>Authentik</th>
|
||||
<th aria-label="Редактирование" />
|
||||
<th aria-label="Порядок" />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<SortableContext items={displayedServices.map((service) => service.id)} strategy={verticalListSortingStrategy}>
|
||||
<tbody>
|
||||
{displayedServices.map((service) => (
|
||||
<SortableServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
onUpdateService={onUpdateService}
|
||||
onOpenContent={() => setContentServiceId(service.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</DndContext>
|
||||
</GlassSurface>
|
||||
|
||||
{contentService ? (
|
||||
|
|
@ -539,6 +546,223 @@ function ServicesSection({
|
|||
);
|
||||
}
|
||||
|
||||
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"
|
||||
value={service.title}
|
||||
onChange={(event) => onUpdateService(service.id, { title: event.target.value })}
|
||||
aria-label={`Название сервиса ${service.title}`}
|
||||
/>
|
||||
<input
|
||||
className="admin-table-input admin-table-input--muted"
|
||||
value={service.subtitle ?? ""}
|
||||
onChange={(event) => onUpdateService(service.id, { subtitle: event.target.value || null })}
|
||||
aria-label={`Подзаголовок сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={service.slug}
|
||||
onChange={(event) => onUpdateService(service.id, { slug: event.target.value })}
|
||||
aria-label={`Slug сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<ServiceStatusDropdown
|
||||
value={service.status}
|
||||
label={`Статус сервиса ${service.title}`}
|
||||
onChange={(status) => onUpdateService(service.id, { status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={service.url}
|
||||
onChange={(event) => onUpdateService(service.id, { url: event.target.value })}
|
||||
aria-label={`URL сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={service.authentikApplicationSlug ?? ""}
|
||||
onChange={(event) => onUpdateService(service.id, { authentikApplicationSlug: event.target.value || null })}
|
||||
aria-label={`Authentik slug сервиса ${service.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton label={`Контент витрины ${service.title}`} className="admin-circle-action services-admin-table__edit" type="button" onClick={onOpenContent}>
|
||||
<Edit3 size={15} />
|
||||
</IconButton>
|
||||
</td>
|
||||
<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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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={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}
|
||||
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_RAIL_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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue