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
+47 -1
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}
/>
+365 -56
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 {
+314 -90
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,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,
+56 -27
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,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);
}
+7 -3
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>