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
+1 -1
View File
@@ -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": {
+3 -3
View File
@@ -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;
+1
View File
@@ -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;
+1 -1
View File
@@ -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)" },
+2 -2
View File
@@ -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"
}
}
+235
View File
@@ -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;
+2 -2
View File
@@ -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"
}
}
+1
View File
@@ -4,3 +4,4 @@ export * from "./floating.js";
export * from "./mediaSource.js";
export * from "./modal.js";
export * from "./select.js";
export * from "./shareLink.js";
+59
View File
@@ -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),
};
}
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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 (
+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>
);
}
+1
View File
@@ -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";