Standardize modal buttons and delete confirmations

This commit is contained in:
DCCONSTRUCTIONS
2026-05-02 12:57:54 +03:00
parent adad0bd344
commit 17e007f49d
21 changed files with 869 additions and 73 deletions
+68
View File
@@ -204,6 +204,13 @@ export function LauncherApp() {
}));
}
function handleDeleteInvite(inviteId: string) {
setData((current) => ({
...current,
invites: current.invites.filter((invite) => invite.id !== inviteId),
}));
}
function handleRetrySync(syncId: string) {
setData((current) => ({
...current,
@@ -275,6 +282,34 @@ export function LauncherApp() {
}));
}
function handleDeleteClient(clientId: string) {
const nextClientId = data.clients.find((client) => client.id !== clientId)?.id ?? activeClientId;
setData((current) => {
if (current.clients.length <= 1) return current;
const deletedGroupIds = new Set(current.groups.filter((group) => group.clientId === clientId).map((group) => group.id));
return {
...current,
clients: current.clients.filter((client) => client.id !== clientId),
memberships: current.memberships.filter((membership) => membership.clientId !== clientId),
groups: current.groups.filter((group) => group.clientId !== clientId),
grants: current.grants.filter(
(grant) =>
!(grant.targetType === "client" && grant.targetId === clientId) &&
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId))
),
invites: current.invites.filter((invite) => invite.clientId !== clientId),
syncStatuses: current.syncStatuses.filter((sync) => sync.objectId !== clientId),
};
});
if (activeClientId === clientId) {
setActiveClientId(nextClientId);
}
}
function handleUpdateUser(userId: string, patch: Partial<LauncherUser>) {
setData((current) => ({
...current,
@@ -305,6 +340,27 @@ export function LauncherApp() {
}));
}
function handleDeleteMembership(membershipId: string) {
setData((current) => {
const membership = current.memberships.find((item) => item.id === membershipId);
if (!membership) return current;
return {
...current,
memberships: current.memberships.filter((item) => item.id !== membershipId),
groups: current.groups.map((group) =>
group.clientId === membership.clientId
? {
...group,
memberIds: group.memberIds.filter((userId) => userId !== membership.userId),
updatedAt: new Date().toISOString(),
}
: group
),
};
});
}
function handleCreateGroup(clientId: string) {
const createdAt = new Date().toISOString();
@@ -340,6 +396,14 @@ export function LauncherApp() {
}));
}
function handleDeleteGroup(groupId: string) {
setData((current) => ({
...current,
groups: current.groups.filter((group) => group.id !== groupId),
grants: current.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId)),
}));
}
function handleReorderServices(orderedServiceIds: string[]) {
setData((current) => {
const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10]));
@@ -443,13 +507,17 @@ export function LauncherApp() {
onSetUserServiceAccess={handleSetUserServiceAccess}
onCreateInvite={handleCreateInvite}
onUpdateInvite={handleUpdateInvite}
onDeleteInvite={handleDeleteInvite}
onRetrySync={handleRetrySync}
onCreateClient={handleCreateClient}
onUpdateClient={handleUpdateClient}
onDeleteClient={handleDeleteClient}
onUpdateUser={handleUpdateUser}
onUpdateMembership={handleUpdateMembership}
onDeleteMembership={handleDeleteMembership}
onCreateGroup={handleCreateGroup}
onUpdateGroup={handleUpdateGroup}
onDeleteGroup={handleDeleteGroup}
onUpdateService={handleUpdateService}
onReorderServices={handleReorderServices}
onCreateService={handleCreateService}
+38
View File
@@ -0,0 +1,38 @@
import type { CSSProperties } from "react";
export type RgbTuple = readonly [number, number, number];
const DARK_TEXT_RGB: RgbTuple = [11, 17, 23];
const LIGHT_TEXT_RGB: RgbTuple = [245, 247, 251];
function clampRgbChannel(value: number): number {
return Math.min(Math.max(Math.round(value), 0), 255);
}
export function formatRgbTuple(rgb: readonly number[]): string {
return rgb.map(clampRgbChannel).join(" ");
}
export function getNodedcRelativeLuminance(rgb: readonly number[]): number {
const [r, g, b] = rgb.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function getReadableNodedcTextRgb(rgb: readonly number[]): RgbTuple {
return getNodedcRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB;
}
export function createNodedcAccentStyleVars(
accentRgb: RgbTuple,
accentVarName = "--nodedc-component-accent-rgb",
onAccentVarName = "--nodedc-component-on-accent-rgb"
): CSSProperties {
return {
[accentVarName]: formatRgbTuple(accentRgb),
[onAccentVarName]: formatRgbTuple(getReadableNodedcTextRgb(accentRgb)),
} as CSSProperties;
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle } from "lucide-react";
import { createNodedcAccentStyleVars, type RgbTuple } from "../lib/accentContrast";
import { Button } from "../ui/Button";
const DELETE_MODAL_ACCENT_RGB: RgbTuple = [247, 248, 244];
export interface NodeDcDeleteModalProps {
isOpen: boolean;
title: string;
description: ReactNode;
cancelLabel?: string;
confirmLabel?: string;
submittingLabel?: string;
accentRgb?: RgbTuple;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}
export function NodeDcDeleteModal({
isOpen,
title,
description,
cancelLabel = "Отмена",
confirmLabel = "Удалить",
submittingLabel = "Удаляем",
accentRgb = DELETE_MODAL_ACCENT_RGB,
onClose,
onConfirm,
}: NodeDcDeleteModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!isOpen) {
setIsSubmitting(false);
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || typeof document === "undefined") return null;
async function handleConfirm() {
if (isSubmitting) return;
setIsSubmitting(true);
try {
await onConfirm();
} finally {
if (mountedRef.current) setIsSubmitting(false);
}
}
return createPortal(
<div className="nodedc-delete-modal-layer" role="presentation" onPointerDown={onClose}>
<article
className="nodedc-delete-modal nodedc-glass-detail-surface"
role="dialog"
aria-modal="true"
aria-labelledby="nodedc-delete-modal-title"
style={createNodedcAccentStyleVars(accentRgb, "--nodedc-delete-accent-rgb", "--nodedc-delete-on-accent-rgb")}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="nodedc-delete-modal__body">
<span className="nodedc-delete-modal__icon" aria-hidden="true">
<AlertTriangle size={21} strokeWidth={1.9} />
</span>
<div className="nodedc-delete-modal__copy">
<h3 id="nodedc-delete-modal-title">{title}</h3>
<div className="nodedc-delete-modal__text">{description}</div>
</div>
</div>
<div className="nodedc-delete-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose} disabled={isSubmitting}>
{cancelLabel}
</Button>
<Button variant="accent" surface="modal" accentRgb={accentRgb} type="button" onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting ? submittingLabel : confirmLabel}
</Button>
</div>
</article>
</div>,
document.body
);
}
+1
View File
@@ -1,4 +1,5 @@
export { NodeDcDropdown } from "./Dropdown";
export { NodeDcDeleteModal, type NodeDcDeleteModalProps } from "./DeleteModal";
export {
NodeDcDateField,
NodeDcCalendar,
+25 -3
View File
@@ -1,16 +1,38 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { createNodedcAccentStyleVars, type RgbTuple } from "../lib/accentContrast";
import { cn } from "../lib/cn";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost" | "accent";
export type ButtonSurface = "default" | "modal";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
surface?: ButtonSurface;
accentRgb?: RgbTuple;
icon?: ReactNode;
}
export function Button({ variant = "secondary", icon, className, children, ...props }: ButtonProps) {
export function Button({
variant = "secondary",
surface = "default",
accentRgb,
icon,
className,
children,
style,
...props
}: ButtonProps) {
const accentStyle = accentRgb
? createNodedcAccentStyleVars(accentRgb, "--nodedc-button-accent-rgb", "--nodedc-button-on-accent-rgb")
: undefined;
return (
<button className={cn("button", `button--${variant}`, className)} {...props}>
<button
className={cn("button", `button--${variant}`, className)}
data-surface={surface}
style={accentStyle ? { ...accentStyle, ...style } : style}
{...props}
>
{icon}
{children ? <span>{children}</span> : null}
</button>
+1 -1
View File
@@ -3,7 +3,7 @@ import { cn } from "../lib/cn";
interface GlassProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
tone?: "default" | "strong" | "soft";
tone?: "default" | "strong" | "soft" | "detail";
}
export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) {
+170 -12
View File
@@ -4,10 +4,13 @@
--nodedc-card-passive-rgb: 42 43 46;
--nodedc-card-active-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-component-accent-rgb: 247 248 244;
--nodedc-component-on-accent-rgb: 11 17 23;
--launcher-radius-modal: 1.75rem;
--launcher-radius-card: 1.35rem;
--launcher-radius-control: 1.25rem;
--launcher-radius-circle: 999px;
--launcher-modal-button-height: 2.75rem;
--surface-base: rgba(8, 8, 11, 0.78);
--surface-strong: rgba(8, 8, 11, 0.9);
--surface-soft: rgba(255, 255, 255, 0.08);
@@ -1060,6 +1063,9 @@ code {
}
.button {
--nodedc-button-accent-rgb: var(--nodedc-component-accent-rgb);
--nodedc-button-on-accent-rgb: var(--nodedc-component-on-accent-rgb);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1094,8 +1100,45 @@ code {
}
.button--danger {
background: rgba(255, 116, 116, 0.16);
color: #ffd5d5;
background: transparent;
color: var(--text-primary);
}
.button--danger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.14);
color: var(--text-primary);
}
.button--accent {
background: rgb(var(--nodedc-button-accent-rgb));
color: rgb(var(--nodedc-button-on-accent-rgb));
}
.button--accent:hover:not(:disabled) {
background: color-mix(in srgb, rgb(var(--nodedc-button-accent-rgb)) 84%, rgba(255, 255, 255, 0.72));
color: rgb(var(--nodedc-button-on-accent-rgb));
}
.button--accent * {
color: rgb(var(--nodedc-button-on-accent-rgb));
}
.button[data-surface="modal"] {
min-height: var(--launcher-modal-button-height);
}
.button[data-surface="modal"].button--secondary,
.button[data-surface="modal"].button--danger,
.button[data-surface="modal"].button--ghost {
background: transparent;
color: var(--text-primary);
}
.button[data-surface="modal"].button--secondary:hover:not(:disabled),
.button[data-surface="modal"].button--danger:hover:not(:disabled),
.button[data-surface="modal"].button--ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.button:disabled {
@@ -2149,6 +2192,131 @@ code {
-webkit-backdrop-filter: blur(12px);
}
.nodedc-delete-modal-layer {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: 1.4rem;
background: rgba(0, 0, 0, 0.42);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.nodedc-glass-detail-surface,
.glass-surface--detail,
.glass-card--detail {
position: relative;
isolation: isolate;
border: 0;
outline: none;
background: rgba(7, 7, 10, 0.54);
box-shadow:
0 34px 120px rgba(0, 0, 0, 0.64),
0 10px 32px rgba(0, 0, 0, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.045);
backdrop-filter: blur(40px) saturate(1.12);
-webkit-backdrop-filter: blur(40px) saturate(1.12);
}
.nodedc-glass-detail-surface::before,
.glass-surface--detail::before,
.glass-card--detail::before {
content: "";
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(5, 5, 8, 0.58);
backdrop-filter: blur(44px) saturate(1.16);
-webkit-backdrop-filter: blur(44px) saturate(1.16);
}
.nodedc-glass-detail-surface::after,
.glass-surface--detail::after,
.glass-card--detail::after {
content: "";
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.035), transparent 44%, rgba(255, 255, 255, 0.018));
pointer-events: none;
}
.nodedc-glass-detail-surface > *,
.glass-surface--detail > *,
.glass-card--detail > * {
position: relative;
z-index: 1;
}
.nodedc-delete-modal {
width: min(34rem, calc(100vw - 2.8rem));
overflow: hidden;
border: 0;
border-radius: var(--launcher-radius-modal);
outline: none;
}
.nodedc-delete-modal__body {
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr);
gap: 0.9rem;
padding: 1.25rem 1.25rem 1rem;
}
.nodedc-delete-modal__icon {
display: grid;
width: 2.75rem;
height: 2.75rem;
place-items: center;
border-radius: var(--launcher-radius-circle);
background: rgba(var(--nodedc-delete-accent-rgb), 0.08);
color: rgb(var(--nodedc-delete-accent-rgb));
}
.nodedc-delete-modal__copy {
min-width: 0;
padding-top: 0.08rem;
}
.nodedc-delete-modal__copy h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.02rem;
font-weight: 850;
letter-spacing: 0;
}
.nodedc-delete-modal__text {
margin-top: 0.42rem;
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 650;
line-height: 1.45;
}
.nodedc-delete-modal__text strong {
color: var(--text-primary);
font-weight: 850;
}
.nodedc-delete-modal__foot {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
padding: 0.9rem 1.25rem 1.15rem;
}
.nodedc-delete-modal .button {
min-width: 8.25rem;
border-radius: var(--launcher-radius-control);
}
.service-content-modal {
position: relative;
display: grid;
@@ -2363,16 +2531,6 @@ code {
font-weight: 750;
}
.service-content-modal .button--primary {
background: rgba(247, 248, 244, 0.96);
color: rgb(var(--nodedc-on-accent-rgb));
}
.service-content-modal .button--secondary {
background: transparent;
color: var(--text-primary);
}
.admin-token-grid {
display: flex;
flex-wrap: wrap;
+197 -19
View File
@@ -63,7 +63,7 @@ import {
import { uploadStorageFile } from "../../shared/api/storageApi";
import { cn } from "../../shared/lib/cn";
import { formatDate, formatDateTime } from "../../shared/lib/format";
import { NodeDcDateField, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
import { NodeDcDateField, NodeDcDeleteModal, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
import { Button, IconButton } from "../../shared/ui/Button";
import { GlassSurface } from "../../shared/ui/Glass";
@@ -118,13 +118,17 @@ export function AdminOverlay({
onSetUserServiceAccess,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
onRetrySync,
onCreateClient,
onUpdateClient,
onDeleteClient,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
onUpdateService,
onReorderServices,
onCreateService,
@@ -137,13 +141,17 @@ export function AdminOverlay({
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
onRetrySync: (syncId: string) => void;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
@@ -155,7 +163,9 @@ export function AdminOverlay({
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
const scopedClientId = isRoot ? selectedClientId : activeClientId;
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
const selectedClientExists = data.clients.some((client) => client.id === selectedClientId);
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
const currentClient = getClient(data, scopedClientId);
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
const selectedAccessCell =
@@ -163,6 +173,13 @@ export function AdminOverlay({
accessMatrix.cells[0] ??
null;
useEffect(() => {
if (isRoot && !selectedClientExists && data.clients.length) {
setSelectedClientId(data.clients[0].id);
setSelectedCell(null);
}
}, [data.clients, isRoot, selectedClientExists]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
@@ -250,7 +267,7 @@ export function AdminOverlay({
<div className="admin-panel-content__body">
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? (
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} />
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} />
) : null}
{activeSection === "users" ? (
<UsersSection
@@ -259,10 +276,17 @@ export function AdminOverlay({
isRoot={isRoot}
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
/>
) : null}
{activeSection === "groups" ? (
<GroupsSection data={data} clientId={scopedClientId} onCreateGroup={onCreateGroup} onUpdateGroup={onUpdateGroup} />
<GroupsSection
data={data}
clientId={scopedClientId}
onCreateGroup={onCreateGroup}
onUpdateGroup={onUpdateGroup}
onDeleteGroup={onDeleteGroup}
/>
) : null}
{activeSection === "services" && isRoot ? (
<ServicesSection
@@ -289,6 +313,7 @@ export function AdminOverlay({
actorUserId={me.user.id}
onCreateInvite={onCreateInvite}
onUpdateInvite={onUpdateInvite}
onDeleteInvite={onDeleteInvite}
/>
) : null}
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
@@ -348,10 +373,12 @@ function ClientsSection({
data,
onCreateClient,
onUpdateClient,
onDeleteClient,
}: {
data: LauncherData;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
}) {
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null;
@@ -453,6 +480,11 @@ function ClientsSection({
onUpdateClient(editingClient.id, patch);
setEditingClientId(null);
}}
onDelete={() => {
onDeleteClient(editingClient.id);
setEditingClientId(null);
}}
canDelete={data.clients.length > 1}
/>
) : null}
</>
@@ -465,12 +497,14 @@ function UsersSection({
isRoot,
onUpdateUser,
onUpdateMembership,
onDeleteMembership,
}: {
data: LauncherData;
clientId: string;
isRoot: boolean;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const rows = isRoot
@@ -579,6 +613,10 @@ function UsersSection({
onUpdateMembership(editingRow.membership.id, membershipPatch);
setEditingMembershipId(null);
}}
onDelete={() => {
onDeleteMembership(editingRow.membership.id);
setEditingMembershipId(null);
}}
/>
) : null}
</>
@@ -590,11 +628,13 @@ function GroupsSection({
clientId,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
}: {
data: LauncherData;
clientId: string;
onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
}) {
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const groups = data.groups.filter((group) => group.clientId === clientId);
@@ -667,6 +707,10 @@ function GroupsSection({
onUpdateGroup(editingGroup.id, patch);
setEditingGroupId(null);
}}
onDelete={() => {
onDeleteGroup(editingGroup.id);
setEditingGroupId(null);
}}
/>
) : null}
</>
@@ -747,6 +791,7 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
];
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
const modalActionAccentRgb = [247, 248, 244] as const;
function ServicesSection({
data,
@@ -1131,6 +1176,7 @@ function ServiceContentModal({
const [draft, setDraft] = useState<Service>(service);
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
setDraft(service);
@@ -1265,15 +1311,17 @@ function ServiceContentModal({
</div>
<div className="service-content-modal__foot">
<Button variant="secondary" type="button" onClick={onClose}>
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена
</Button>
<div className="service-content-modal__foot-actions">
<Button variant="danger" type="button" icon={<Trash2 size={16} />} onClick={onDelete}>
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
Удалить
</Button>
<Button
variant="primary"
variant="accent"
surface="modal"
accentRgb={modalActionAccentRgb}
type="button"
disabled={uploadingSlot !== null}
icon={<Save size={16} />}
@@ -1300,6 +1348,20 @@ function ServiceContentModal({
</div>
</div>
</article>
<NodeDcDeleteModal
isOpen={deleteOpen}
title="Удалить витрину"
description={
<>
Витрина <strong>{service.title}</strong> будет удалена из каталога, нижней панели и матрицы доступов.
</>
}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
onDelete();
}}
/>
</div>
);
}
@@ -1308,10 +1370,14 @@ function ClientEditorModal({
client,
onClose,
onSave,
onDelete,
canDelete,
}: {
client: Client;
onClose: () => void;
onSave: (patch: Partial<Client>) => void;
onDelete: () => void;
canDelete: boolean;
}) {
const [draft, setDraft] = useState<Client>(client);
@@ -1394,7 +1460,24 @@ function ClientEditorModal({
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
</label>
</div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} />
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={
canDelete
? {
label: "Удалить",
title: "Удалить компанию",
description: (
<>
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
</>
),
onConfirm: onDelete,
}
: undefined
}
/>
</article>
</div>
);
@@ -1406,12 +1489,14 @@ function UserEditorModal({
client,
onClose,
onSave,
onDelete,
}: {
user: LauncherUser;
membership: ClientMembership;
client: Client;
onClose: () => void;
onSave: (userPatch: Partial<LauncherUser>, membershipPatch: Partial<ClientMembership>) => void;
onDelete: () => void;
}) {
const [userDraft, setUserDraft] = useState<LauncherUser>(user);
const [membershipDraft, setMembershipDraft] = useState<ClientMembership>(membership);
@@ -1479,7 +1564,20 @@ function UserEditorModal({
<textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} />
</label>
</div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(userDraft, membershipDraft)} />
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(userDraft, membershipDraft)}
deleteConfig={{
label: "Удалить",
title: "Удалить участника",
description: (
<>
Участник <strong>{user.name}</strong> будет удален из клиента <strong>{client.name}</strong>. Глобальный профиль пользователя останется в демо-справочнике.
</>
),
onConfirm: onDelete,
}}
/>
</article>
</div>
);
@@ -1490,11 +1588,13 @@ function GroupEditorModal({
users,
onClose,
onSave,
onDelete,
}: {
group: ClientGroup;
users: LauncherUser[];
onClose: () => void;
onSave: (patch: Partial<ClientGroup>) => void;
onDelete: () => void;
}) {
const [draft, setDraft] = useState<ClientGroup>(group);
@@ -1543,7 +1643,20 @@ function GroupEditorModal({
</div>
</div>
</div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} />
<EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={{
label: "Удалить",
title: "Удалить группу",
description: (
<>
Группа <strong>{group.name}</strong> и привязанные к ней гранты сервисов будут удалены.
</>
),
onConfirm: onDelete,
}}
/>
</article>
</div>
);
@@ -1563,16 +1676,52 @@ function EntityModalHead({ eyebrow, title, onClose }: { eyebrow: string; title:
);
}
function EntityModalFoot({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
function EntityModalFoot({
onClose,
onSave,
deleteConfig,
}: {
onClose: () => void;
onSave: () => void;
deleteConfig?: {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<div className="service-content-modal__foot">
<Button variant="secondary" type="button" onClick={onClose}>
Отмена
</Button>
<Button variant="primary" type="button" icon={<Save size={16} />} onClick={onSave}>
Сохранить
</Button>
</div>
<>
<div className="service-content-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена
</Button>
<div className="service-content-modal__foot-actions">
{deleteConfig ? (
<Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
{deleteConfig.label}
</Button>
) : null}
<Button variant="accent" surface="modal" accentRgb={modalActionAccentRgb} type="button" icon={<Save size={16} />} onClick={onSave}>
Сохранить
</Button>
</div>
</div>
{deleteConfig ? (
<NodeDcDeleteModal
isOpen={deleteOpen}
title={deleteConfig.title}
description={deleteConfig.description}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
deleteConfig.onConfirm();
}}
/>
) : null}
</>
);
}
@@ -1821,16 +1970,20 @@ function InvitesSection({
actorUserId,
onCreateInvite,
onUpdateInvite,
onDeleteInvite,
}: {
data: LauncherData;
clientId: string;
actorUserId: string;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
}) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<ClientMembershipRole>("member");
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
const invites = data.invites.filter((invite) => invite.clientId === clientId);
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
const actor = getUser(data, actorUserId);
function handleCreateInvite() {
@@ -1884,6 +2037,7 @@ function InvitesSection({
<th>Статус</th>
<th>Ссылка</th>
<th>Истекает</th>
<th aria-label="Удаление" />
</tr>
</thead>
<tbody>
@@ -1938,11 +2092,35 @@ function InvitesSection({
}}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Удалить инвайт ${invite.email}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setDeleteInviteId(invite.id)}
>
<Trash2 size={15} />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</GlassSurface>
<NodeDcDeleteModal
isOpen={Boolean(deletingInvite)}
title="Удалить инвайт"
description={
<>
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
</>
}
onClose={() => setDeleteInviteId(null)}
onConfirm={() => {
if (deletingInvite) onDeleteInvite(deletingInvite.id);
setDeleteInviteId(null);
}}
/>
</div>
);
}