feat: refine launcher admin and showcase UI
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user