Add pink media stage and sharing modal system
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/tokens",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"files": ["tokens.json", "tokens.css", "themes.css"],
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:root,
|
||||
[data-nodedc-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--nodedc-accent-rgb: 195 255 102;
|
||||
--nodedc-accent-rgb: 255 47 146;
|
||||
--nodedc-on-accent-rgb: 11 17 23;
|
||||
--nodedc-danger-rgb: 255 116 116;
|
||||
--nodedc-warning-rgb: 255 209 102;
|
||||
@@ -35,8 +35,8 @@
|
||||
|
||||
[data-nodedc-theme="light"] {
|
||||
color-scheme: light;
|
||||
--nodedc-accent-rgb: 32 32 34;
|
||||
--nodedc-on-accent-rgb: 247 248 244;
|
||||
--nodedc-accent-rgb: 255 47 146;
|
||||
--nodedc-on-accent-rgb: 11 17 23;
|
||||
--nodedc-danger-rgb: 205 47 67;
|
||||
--nodedc-warning-rgb: 173 111 0;
|
||||
--nodedc-success-rgb: 46 125 76;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
--nodedc-inspector-button-radius: 1rem;
|
||||
--nodedc-select-toggle-width: 2.875rem;
|
||||
--nodedc-select-gap: 0.5rem;
|
||||
--nodedc-icon-glyph-scale: 0.9;
|
||||
|
||||
--nodedc-space-1: 0.25rem;
|
||||
--nodedc-space-2: 0.5rem;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": "1.0.0",
|
||||
"name": "NODE.DC Design Tokens",
|
||||
"color": {
|
||||
"accentRgb": { "$type": "color", "$value": "rgb(195 255 102)" },
|
||||
"accentRgb": { "$type": "color", "$value": "rgb(255 47 146)" },
|
||||
"onAccentRgb": { "$type": "color", "$value": "rgb(11 17 23)" },
|
||||
"dangerRgb": { "$type": "color", "$value": "rgb(255 116 116)" },
|
||||
"warningRgb": { "$type": "color", "$value": "rgb(255 209 102)" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"files": ["dist", "styles.css"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -17,6 +17,6 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "0.4.0"
|
||||
"@nodedc/tokens": "0.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
.nodedc-button__icon > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||
}
|
||||
|
||||
.nodedc-icon-button {
|
||||
@@ -213,6 +214,16 @@
|
||||
.nodedc-icon-button > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__nav-icon > svg,
|
||||
.nodedc-application-panel__action > svg,
|
||||
.nodedc-dropdown-option svg,
|
||||
.nodedc-select__toggle > svg,
|
||||
.nodedc-confirmation__icon > svg,
|
||||
.nodedc-media-preview > svg {
|
||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||
}
|
||||
|
||||
.nodedc-field {
|
||||
@@ -1905,6 +1916,212 @@ textarea.nodedc-field__control {
|
||||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
.nodedc-window.nodedc-share-access-window {
|
||||
width: min(37.5rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.nodedc-window.nodedc-share-access-window,
|
||||
.nodedc-share-access-window .nodedc-window__body {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.nodedc-share-access {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__members,
|
||||
.nodedc-share-access__member-list,
|
||||
.nodedc-share-access__identity,
|
||||
.nodedc-share-access__readonly {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-share-access__members {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
gap: 0.62rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__section-label {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.nodedc-share-access__member-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
left: 0;
|
||||
z-index: var(--nodedc-layer-popover);
|
||||
width: 100%;
|
||||
max-height: 18rem;
|
||||
gap: 0.25rem;
|
||||
overflow-y: auto;
|
||||
border-radius: var(--nodedc-radius-panel);
|
||||
background: var(--nodedc-glass-panel-surface-strong);
|
||||
padding: 0.6rem;
|
||||
box-shadow: var(--nodedc-glass-dropdown-shadow);
|
||||
backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.2);
|
||||
}
|
||||
|
||||
.nodedc-share-access__summary {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 5.375rem;
|
||||
height: 2.375rem;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-share-access__summary-avatars {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 4.25rem;
|
||||
height: 2.125rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__summary-avatar {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
display: grid;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(42, 43, 46, 0.96);
|
||||
color: var(--nodedc-text-primary);
|
||||
box-shadow: 0 0 0 1px var(--nodedc-canvas);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-share-access__summary-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-share-access__summary-count {
|
||||
position: absolute;
|
||||
top: -0.05rem;
|
||||
right: 0.1rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-share-access__member {
|
||||
display: grid;
|
||||
min-height: var(--nodedc-control-height);
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) 8.75rem auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 0.28rem 0.35rem 0.28rem 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__avatar {
|
||||
display: grid;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-share-access__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-share-access__identity {
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__identity strong,
|
||||
.nodedc-share-access__identity small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-share-access__identity strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.nodedc-share-access__identity small,
|
||||
.nodedc-share-access__role {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.nodedc-share-access__member .nodedc-select-trigger {
|
||||
min-height: var(--nodedc-control-height-compact);
|
||||
}
|
||||
|
||||
.nodedc-share-access__member .nodedc-icon-button {
|
||||
width: var(--nodedc-control-height-compact);
|
||||
height: var(--nodedc-control-height-compact);
|
||||
flex-basis: var(--nodedc-control-height-compact);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-share-access__empty,
|
||||
.nodedc-share-access__readonly {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.9rem 1rem;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.nodedc-share-access__invite {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 11rem;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__readonly {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__readonly strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-share-access__message {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nodedc-share-access__message[data-tone="success"],
|
||||
.nodedc-share-access__message[data-tone="accent"] {
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-share-access__message[data-tone="danger"] {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.nodedc-confirmation {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -2144,6 +2361,24 @@ textarea.nodedc-field__control {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__member {
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.nodedc-share-access__member-list {
|
||||
position: static;
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.nodedc-share-access__member .nodedc-dropdown-anchor,
|
||||
.nodedc-share-access__role {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
.nodedc-share-access__invite {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-control-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-dom",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -16,6 +16,6 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.4.0"
|
||||
"@nodedc/ui-core": "0.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./floating.js";
|
||||
export * from "./mediaSource.js";
|
||||
export * from "./modal.js";
|
||||
export * from "./select.js";
|
||||
export * from "./shareLink.js";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface ShareLinkControllerOptions {
|
||||
input: HTMLInputElement;
|
||||
copyControl: HTMLButtonElement;
|
||||
copyLabel?: string;
|
||||
copiedLabel?: string;
|
||||
onCopy?: (link: string) => void | Promise<void>;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface ShareLinkController {
|
||||
getLink: () => string;
|
||||
setLink: (link: string) => void;
|
||||
copy: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createShareLinkController({
|
||||
input,
|
||||
copyControl,
|
||||
copyLabel = "Скопировать",
|
||||
copiedLabel = "Скопировано",
|
||||
onCopy,
|
||||
onError,
|
||||
}: ShareLinkControllerOptions): ShareLinkController {
|
||||
input.readOnly = true;
|
||||
|
||||
const render = (copied = false) => {
|
||||
copyControl.disabled = !input.value;
|
||||
copyControl.dataset.copied = copied ? "true" : "false";
|
||||
copyControl.textContent = copied ? copiedLabel : copyLabel;
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
const link = input.value;
|
||||
if (!link) return;
|
||||
try {
|
||||
if (onCopy) await onCopy(link);
|
||||
else await navigator.clipboard.writeText(link);
|
||||
render(true);
|
||||
} catch (error) {
|
||||
render(false);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => void copy();
|
||||
copyControl.addEventListener("click", handleClick);
|
||||
render();
|
||||
|
||||
return {
|
||||
getLink: () => input.value,
|
||||
setLink: (link) => {
|
||||
input.value = link;
|
||||
render(false);
|
||||
},
|
||||
copy,
|
||||
destroy: () => copyControl.removeEventListener("click", handleClick),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -16,7 +16,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.4.0",
|
||||
"@nodedc/ui-core": "0.5.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -97,7 +97,7 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Icon({ name, label, size = 18, strokeWidth = 1.8, ...props }: IconProps) {
|
||||
export function Icon({ name, label, size = 17, strokeWidth = 1.7, ...props }: IconProps) {
|
||||
const IconComponent = icons[name];
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export * from "./SegmentedControl.js";
|
||||
export * from "./Select.js";
|
||||
export * from "./StatusBadge.js";
|
||||
export * from "./Settings.js";
|
||||
export * from "./SharingModals.js";
|
||||
export * from "./Window.js";
|
||||
|
||||
Reference in New Issue
Block a user