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
+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>