Add pink media stage and sharing modal system

This commit is contained in:
DCCONSTRUCTIONS
2026-07-10 18:21:14 +03:00
parent ebae662b52
commit f1e28c267a
29 changed files with 917 additions and 64 deletions
+246
View File
@@ -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>
);
}