Add pink media stage and sharing modal system
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
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="accent" 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="accent" icon={<Icon name={copied ? "check" : "copy"} />} onClick={() => void copy()} disabled={!link}>
|
||||
{copied ? "Скопировано" : copyLabel}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField label="Публичная ссылка" value={link} readOnly description={hint} />
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user