250 lines
9.1 KiB
TypeScript
250 lines
9.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Button, IconButton } from "./Button.js";
|
|
import { Icon } from "./Icon.js";
|
|
import { Select, type SelectOption } from "./Select.js";
|
|
import { TextField } from "./Field.js";
|
|
import { Window, WindowFooterActions } from "./Window.js";
|
|
|
|
export interface ShareAccessMember<Role extends string = string> {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role: Role;
|
|
roleLabel?: string;
|
|
avatarUrl?: string;
|
|
immutable?: boolean;
|
|
}
|
|
|
|
export interface ShareAccessModalProps<Role extends string = string> {
|
|
open: boolean;
|
|
resourceName: string;
|
|
members: readonly ShareAccessMember<Role>[];
|
|
roleOptions: Array<SelectOption<Role>>;
|
|
inviteEmail: string;
|
|
inviteRole: Role;
|
|
canManage?: boolean;
|
|
pending?: boolean;
|
|
message?: string;
|
|
messageTone?: "neutral" | "success" | "danger" | "accent";
|
|
onInviteEmailChange: (email: string) => void;
|
|
onInviteRoleChange: (role: Role) => void;
|
|
onInvite: () => void | Promise<void>;
|
|
onMemberRoleChange?: (member: ShareAccessMember<Role>, role: Role) => void;
|
|
onMemberRemove?: (member: ShareAccessMember<Role>) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function initials(name: string, email: string) {
|
|
const words = (name || email).split(/[\s._@-]+/).filter(Boolean);
|
|
return `${words[0]?.[0] ?? "N"}${words[1]?.[0] ?? words[0]?.[1] ?? ""}`.toUpperCase();
|
|
}
|
|
|
|
export function ShareAccessModal<Role extends string>({
|
|
open,
|
|
resourceName,
|
|
members,
|
|
roleOptions,
|
|
inviteEmail,
|
|
inviteRole,
|
|
canManage = true,
|
|
pending = false,
|
|
message,
|
|
messageTone = "neutral",
|
|
onInviteEmailChange,
|
|
onInviteRoleChange,
|
|
onInvite,
|
|
onMemberRoleChange,
|
|
onMemberRemove,
|
|
onClose,
|
|
}: ShareAccessModalProps<Role>) {
|
|
const [membersOpen, setMembersOpen] = useState(false);
|
|
const membersRootRef = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) setMembersOpen(false);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!membersOpen) return;
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (!(event.target instanceof Node)) return;
|
|
if (event.target instanceof Element && event.target.closest(".nodedc-dropdown-surface")) return;
|
|
if (!membersRootRef.current?.contains(event.target)) setMembersOpen(false);
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return;
|
|
// An open nested dropdown owns the first Escape press. Let it close and
|
|
// restore focus without collapsing the surrounding members disclosure.
|
|
if (document.querySelector(".nodedc-dropdown-surface")) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
setMembersOpen(false);
|
|
};
|
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
document.addEventListener("keydown", handleKeyDown, true);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
document.removeEventListener("keydown", handleKeyDown, true);
|
|
};
|
|
}, [membersOpen]);
|
|
|
|
return (
|
|
<Window
|
|
open={open}
|
|
title="Поделиться графом"
|
|
subtitle={resourceName.toUpperCase()}
|
|
className="nodedc-share-access-window"
|
|
closeOnBackdrop={false}
|
|
onClose={onClose}
|
|
footer={
|
|
<WindowFooterActions>
|
|
<Button onClick={onClose} disabled={pending}>Отменить</Button>
|
|
{canManage ? (
|
|
<Button variant="primary" onClick={() => void onInvite()} disabled={pending || !inviteEmail.trim()}>
|
|
{pending ? "Сохранение..." : "Поделиться"}
|
|
</Button>
|
|
) : null}
|
|
</WindowFooterActions>
|
|
}
|
|
>
|
|
<div className="nodedc-share-access">
|
|
<section ref={membersRootRef} className="nodedc-share-access__members" aria-label="Пользователи с доступом">
|
|
<span className="nodedc-share-access__section-label">Уже добавлены</span>
|
|
{members.length ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="nodedc-share-access__summary"
|
|
aria-label={`Пользователи с доступом: ${members.length}`}
|
|
aria-expanded={membersOpen}
|
|
onClick={() => setMembersOpen((current) => !current)}
|
|
>
|
|
<span className="nodedc-share-access__summary-avatars" aria-hidden="true">
|
|
{members.slice(0, 3).map((member, index) => (
|
|
<span className="nodedc-share-access__summary-avatar" style={{ left: index * 18, zIndex: 10 + index }} key={member.id}>
|
|
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : initials(member.name, member.email)}
|
|
</span>
|
|
))}
|
|
</span>
|
|
<span className="nodedc-share-access__summary-count">{members.length}</span>
|
|
</button>
|
|
</>
|
|
) : <div className="nodedc-share-access__empty">Пока никому не открыт доступ</div>}
|
|
{membersOpen ? (
|
|
<div className="nodedc-share-access__member-list">
|
|
{members.map((member) => (
|
|
<div className="nodedc-share-access__member" key={member.id}>
|
|
<span className="nodedc-share-access__avatar" aria-hidden="true">
|
|
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : initials(member.name, member.email)}
|
|
</span>
|
|
<span className="nodedc-share-access__identity">
|
|
<strong>{member.name || member.email}</strong>
|
|
{member.name && member.name !== member.email ? <small>{member.email}</small> : null}
|
|
</span>
|
|
{canManage && !member.immutable && onMemberRoleChange ? (
|
|
<Select
|
|
value={member.role}
|
|
label={`Роль ${member.email}`}
|
|
options={roleOptions}
|
|
minMenuWidth={150}
|
|
onChange={(role) => onMemberRoleChange(member, role)}
|
|
/>
|
|
) : <span className="nodedc-share-access__role">{member.roleLabel ?? member.role}</span>}
|
|
{canManage && !member.immutable && onMemberRemove ? (
|
|
<IconButton label={`Удалить доступ ${member.email}`} onClick={() => onMemberRemove(member)}>
|
|
<Icon name="trash" />
|
|
</IconButton>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{canManage ? (
|
|
<div className="nodedc-share-access__invite">
|
|
<TextField
|
|
label="Пользователь"
|
|
type="email"
|
|
value={inviteEmail}
|
|
disabled={pending}
|
|
placeholder="email@company.ru"
|
|
onChange={(event) => onInviteEmailChange(event.target.value)}
|
|
/>
|
|
<div className="nodedc-field">
|
|
<span className="nodedc-field__label-row"><span className="nodedc-field__label">Роль</span></span>
|
|
<Select
|
|
value={inviteRole}
|
|
label="Роль доступа"
|
|
options={roleOptions}
|
|
disabled={pending}
|
|
onChange={(role) => onInviteRoleChange(role)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="nodedc-share-access__readonly">
|
|
<strong>Вы не можете пригласить пользователя</strong>
|
|
<span>Попросите владельца изменить ваш уровень доступа.</span>
|
|
</div>
|
|
)}
|
|
|
|
{message ? <div className="nodedc-share-access__message" data-tone={messageTone}>{message}</div> : null}
|
|
</div>
|
|
</Window>
|
|
);
|
|
}
|
|
|
|
export interface ShareLinkModalProps {
|
|
open: boolean;
|
|
title?: string;
|
|
link: string;
|
|
hint?: string;
|
|
copyLabel?: string;
|
|
onCopy?: (link: string) => void | Promise<void>;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ShareLinkModal({
|
|
open,
|
|
title = "Ссылка на модель",
|
|
link,
|
|
hint = "Ссылка открывает текущую модель и сохранённую точку обзора.",
|
|
copyLabel = "Скопировать",
|
|
onCopy,
|
|
onClose,
|
|
}: ShareLinkModalProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) setCopied(false);
|
|
}, [open]);
|
|
|
|
const copy = async () => {
|
|
if (onCopy) await onCopy(link);
|
|
else await navigator.clipboard.writeText(link);
|
|
setCopied(true);
|
|
};
|
|
|
|
return (
|
|
<Window
|
|
open={open}
|
|
title={title}
|
|
size="sm"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button onClick={onClose}>Закрыть</Button>
|
|
<WindowFooterActions>
|
|
<Button variant="primary" icon={<Icon name={copied ? "check" : "copy"} />} onClick={() => void copy()} disabled={!link}>
|
|
{copied ? "Скопировано" : copyLabel}
|
|
</Button>
|
|
</WindowFooterActions>
|
|
</>
|
|
}
|
|
>
|
|
<TextField label="Публичная ссылка" value={link} readOnly description={hint} />
|
|
</Window>
|
|
);
|
|
}
|