Add pink media stage and sharing modal system
This commit is contained in:
parent
ebae662b52
commit
f1e28c267a
|
|
@ -1,5 +1,14 @@
|
|||
# Changelog
|
||||
|
||||
## 0.5.0 — media stage and modal inventory
|
||||
|
||||
- Switched the default NODE.DC application accent to pink while retaining live accent editing and semantic status colors.
|
||||
- Reduced icon glyph weight/size by roughly 10% without changing button and circular hit-target geometry.
|
||||
- Connected `MediaSourceField` MP4 uploads to the Launcher-style catalog stage with safe object URL replacement and cleanup.
|
||||
- Added the Engine-derived `ShareAccessModal` with compact member stack, roles, invitation and immutable owner states.
|
||||
- Added the BIM-derived `ShareLinkModal` and framework-neutral `createShareLinkController`.
|
||||
- Audited BIM form, confirmation, share-link, contextual action, expandable detail and version-history modals and added runnable catalog specimens.
|
||||
|
||||
## 0.4.0 — package-native application contract
|
||||
|
||||
- Corrected `AppHeader` against the production Launcher two-layer geometry: 68 px outer toolbar and 48 px visible row.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
- launcher-style ApplicationShell без обязательной нижней витрины;
|
||||
- единый state-controller открытия navigation/content окон;
|
||||
- CMS/Launcher media field: файл, URL, превью и storage-adapter;
|
||||
- Engine workflow sharing и BIM share-link modal;
|
||||
- каталог form/confirmation/action/detail/history modal-паттернов;
|
||||
- аудированный общий набор иконок;
|
||||
- инспектор/панель настроек нового поколения;
|
||||
- поведение portal-слоёв, Escape, outside click и фокуса;
|
||||
|
|
@ -51,7 +53,7 @@ npm run build
|
|||
|
||||
## Статус
|
||||
|
||||
Версия `0.4.x` — пакетный Launcher-пресет шапки и оконного workspace, media/settings patterns, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются.
|
||||
Версия `0.5.x` — розовый NODE.DC accent, runtime MP4 stage media, workflow/BIM sharing modals и полная карта оконных паттернов. Исходные приложения на первом этапе не изменяются.
|
||||
|
||||
Подробности:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@nodedc/ui-catalog",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -9,8 +9,8 @@
|
|||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.4.0",
|
||||
"@nodedc/ui-react": "0.4.0",
|
||||
"@nodedc/ui-core": "0.5.0",
|
||||
"@nodedc/ui-react": "0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
|
|
@ -24,6 +24,8 @@ import {
|
|||
MediaSourceField,
|
||||
RangeControl,
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
|
|
@ -33,22 +35,31 @@ import {
|
|||
Window,
|
||||
WindowFooterActions,
|
||||
type IconName,
|
||||
type ShareAccessMember,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "inspector" | "icons";
|
||||
|
||||
const engineAccentStyle = {
|
||||
"--nodedc-accent-rgb": "148 123 219",
|
||||
"--nodedc-on-accent-rgb": "247 248 244",
|
||||
"--nodedc-accent-rgb": "255 47 146",
|
||||
"--nodedc-on-accent-rgb": "11 17 23",
|
||||
} as CSSProperties;
|
||||
|
||||
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
|
||||
{ label: "Hub", value: [195, 255, 102], hex: "#c3ff66" },
|
||||
{ label: "Engine", value: [148, 123, 219], hex: "#947bdb" },
|
||||
{ label: "BIM", value: [78, 164, 255], hex: "#4ea4ff" },
|
||||
{ label: "SEO", value: [32, 32, 34], hex: "#202022" },
|
||||
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
|
||||
{ label: "Rose", value: [255, 62, 108], hex: "#ff3e6c" },
|
||||
{ label: "Blush", value: [255, 108, 175], hex: "#ff6caf" },
|
||||
{ label: "Magenta", value: [215, 70, 255], hex: "#d746ff" },
|
||||
];
|
||||
|
||||
type ShareRole = "viewer" | "editor" | "admin";
|
||||
|
||||
const shareRoleOptions = [
|
||||
{ value: "viewer", label: "Просмотр" },
|
||||
{ value: "editor", label: "Редактор" },
|
||||
{ value: "admin", label: "Соавтор" },
|
||||
] satisfies Array<{ value: ShareRole; label: string }>;
|
||||
|
||||
const selectOptions = [
|
||||
{ value: "active", label: "Активен", description: "Сервис доступен пользователям" },
|
||||
{ value: "maintenance", label: "Техработы", description: "Временно недоступен" },
|
||||
|
|
@ -83,7 +94,7 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
|
|||
modals: {
|
||||
eyebrow: "05 / MODALS",
|
||||
title: "Модалки",
|
||||
description: "Создание, подтверждение и разрушительные действия в адаптивном формате.",
|
||||
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
|
||||
icon: "clipboard",
|
||||
},
|
||||
inspector: {
|
||||
|
|
@ -207,7 +218,7 @@ function Preview({ title, note, children, className }: {
|
|||
|
||||
export function CatalogApp() {
|
||||
const [theme, setTheme] = useState<NodedcTheme>("dark");
|
||||
const [accentHex, setAccentHex] = useState("#c3ff66");
|
||||
const [accentHex, setAccentHex] = useState("#ff2f92");
|
||||
const workspace = useApplicationWorkspace<CatalogSection>();
|
||||
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
||||
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
|
||||
|
|
@ -216,20 +227,41 @@ export function CatalogApp() {
|
|||
const [glowDistance, setGlowDistance] = useState(105);
|
||||
const [usePortColors, setUsePortColors] = useState(false);
|
||||
const [connectionType, setConnectionType] = useState("spline");
|
||||
const [lightColor, setLightColor] = useState("#7a3cff");
|
||||
const [lightColor, setLightColor] = useState("#ff2f92");
|
||||
const [connectionColor, setConnectionColor] = useState("#404040");
|
||||
const [fillColor, setFillColor] = useState("#8f83ff");
|
||||
const [fillColor, setFillColor] = useState("#ff6caf");
|
||||
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
||||
const [windowOpen, setWindowOpen] = useState(false);
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
||||
const [shareLinkOpen, setShareLinkOpen] = useState(false);
|
||||
const [contextActionOpen, setContextActionOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailExpanded, setDetailExpanded] = useState(false);
|
||||
const [projectName, setProjectName] = useState("NODE.DC Platform");
|
||||
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
|
||||
const [mediaSource, setMediaSource] = useState<"file" | "url">("file");
|
||||
const [mediaUrl, setMediaUrl] = useState("");
|
||||
const [mediaFileName, setMediaFileName] = useState("hub_vitrina-mqv7yd7k.mov");
|
||||
const [mediaFileName, setMediaFileName] = useState("launcher-stage.mp4");
|
||||
const [fileMediaSrc, setFileMediaSrc] = useState("/launcher-stage.mp4");
|
||||
const [mediaError, setMediaError] = useState("");
|
||||
const [mediaVisible, setMediaVisible] = useState(true);
|
||||
const mediaObjectUrlRef = useRef<string | null>(null);
|
||||
const [shareEmail, setShareEmail] = useState("");
|
||||
const [shareRole, setShareRole] = useState<ShareRole>("editor");
|
||||
const [shareMessage, setShareMessage] = useState("");
|
||||
const [shareMembers, setShareMembers] = useState<Array<ShareAccessMember<ShareRole>>>([
|
||||
{ id: "owner", name: "DC Constructions", email: "dc@nodedc.ru", role: "admin", roleLabel: "Владелец", immutable: true },
|
||||
{ id: "editor", name: "Maria Petrova", email: "maria@nodedc.ru", role: "editor", roleLabel: "Редактор" },
|
||||
]);
|
||||
|
||||
const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl)
|
||||
? mediaUrl
|
||||
: null;
|
||||
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc;
|
||||
|
||||
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
|
||||
|
||||
|
|
@ -237,6 +269,38 @@ export function CatalogApp() {
|
|||
applyNodedcTheme(document.documentElement, { theme, accent });
|
||||
}, [accent, theme]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||
}, []);
|
||||
|
||||
const handleStageMediaFile = (file?: File) => {
|
||||
if (!file) return;
|
||||
const isMp4 = file.type === "video/mp4" || /\.mp4$/i.test(file.name);
|
||||
if (!isMp4) {
|
||||
setMediaError("Для заставки нужен MP4-файл.");
|
||||
return;
|
||||
}
|
||||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
mediaObjectUrlRef.current = objectUrl;
|
||||
setFileMediaSrc(objectUrl);
|
||||
setMediaFileName(file.name);
|
||||
setMediaSource("file");
|
||||
setMediaVisible(true);
|
||||
setMediaError("");
|
||||
};
|
||||
|
||||
const inviteShareMember = () => {
|
||||
const email = shareEmail.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
setShareMembers((current) => [
|
||||
...current.filter((member) => member.email !== email),
|
||||
{ id: email, name: email.split("@")[0], email, role: shareRole, roleLabel: shareRoleOptions.find((item) => item.value === shareRole)?.label },
|
||||
]);
|
||||
setShareMessage(`Доступ выдан пользователю ${email}`);
|
||||
setShareEmail("");
|
||||
};
|
||||
|
||||
const openSection = (next: string) => {
|
||||
workspace.openView(next as CatalogSection);
|
||||
};
|
||||
|
|
@ -425,15 +489,18 @@ export function CatalogApp() {
|
|||
source={mediaSource}
|
||||
url={mediaUrl}
|
||||
fileName={mediaFileName}
|
||||
previewSrc={mediaSource === "url" && mediaUrl ? mediaUrl : "/launcher-stage-poster.png"}
|
||||
previewKind={mediaSource === "file" ? "image" : undefined}
|
||||
path="content.videoSrc → assets/uploads/media/component-library"
|
||||
hint="Адаптер приложения сохраняет файл и возвращает URL; компонент не знает API storage."
|
||||
previewSrc={stageMediaSrc}
|
||||
previewKind="video"
|
||||
accept="video/mp4,.mp4"
|
||||
path="stage.videoSrc → runtime media source"
|
||||
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage."
|
||||
error={mediaError}
|
||||
onSourceChange={setMediaSource}
|
||||
onUrlChange={setMediaUrl}
|
||||
onFileChange={(file) => {
|
||||
if (file) setMediaFileName(file.name);
|
||||
onUrlChange={(nextUrl) => {
|
||||
setMediaUrl(nextUrl);
|
||||
setMediaError(nextUrl && !/\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на видеофайл." : "");
|
||||
}}
|
||||
onFileChange={handleStageMediaFile}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide">
|
||||
|
|
@ -474,6 +541,26 @@ export function CatalogApp() {
|
|||
<p className="catalog-preview__explanation">Destructive-цвет появляется у подтверждения, а не окрашивает всё окно заранее.</p>
|
||||
<Button variant="danger" shape="pill" icon={<Icon name="trash" />} onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
|
||||
</Preview>
|
||||
<Preview title="Workflow sharing" note="ENGINE / access modal">
|
||||
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец. API доступа остаётся в Engine.</p>
|
||||
<Button variant="accent" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
|
||||
</Preview>
|
||||
<Preview title="Ссылка на модель" note="BIM / share link">
|
||||
<p className="catalog-preview__explanation">Read-only ссылка, copy-state и единая механика focus/Escape/backdrop.</p>
|
||||
<Button shape="pill" icon={<Icon name="copy" />} onClick={() => setShareLinkOpen(true)}>Открыть ссылку</Button>
|
||||
</Preview>
|
||||
<Preview title="Контекстные действия" note="BIM / measurement & object">
|
||||
<p className="catalog-preview__explanation">Короткое окно действий над выбранным размером или объектом без отдельной modal-системы.</p>
|
||||
<Button shape="pill" icon={<Icon name="sliders" />} onClick={() => setContextActionOpen(true)}>Действия размера</Button>
|
||||
</Preview>
|
||||
<Preview title="История версий" note="BIM / large data modal">
|
||||
<p className="catalog-preview__explanation">Большое прокручиваемое data-окно; BIM-таблица остаётся доменной композицией внутри общего Window.</p>
|
||||
<Button shape="pill" icon={<Icon name="list" />} onClick={() => setHistoryOpen(true)}>Открыть историю</Button>
|
||||
</Preview>
|
||||
<Preview title="Комментарий к модели" note="BIM / expandable detail">
|
||||
<p className="catalog-preview__explanation">Рабочая detail-modal с контентом и вложениями, которая может расширяться без смены механики слоя.</p>
|
||||
<Button shape="pill" icon={<Icon name="clipboard" />} onClick={() => setDetailOpen(true)}>Открыть комментарий</Button>
|
||||
</Preview>
|
||||
</div>
|
||||
);
|
||||
case "inspector":
|
||||
|
|
@ -553,8 +640,8 @@ export function CatalogApp() {
|
|||
}
|
||||
stage={
|
||||
<section className="catalog-launcher-stage">
|
||||
<video className="catalog-launcher-stage__media" autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
|
||||
<source src="/launcher-stage.mp4" type="video/mp4" />
|
||||
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
|
||||
<source src={stageMediaSrc} type="video/mp4" />
|
||||
</video>
|
||||
<div className="catalog-launcher-stage__shade" />
|
||||
<div className="catalog-launcher-stage__title">
|
||||
|
|
@ -581,7 +668,7 @@ export function CatalogApp() {
|
|||
footer={
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
|
||||
<span>Design System 0.4</span>
|
||||
<span>Design System 0.5</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
@ -651,6 +738,105 @@ export function CatalogApp() {
|
|||
</div>
|
||||
</Window>
|
||||
|
||||
<ShareAccessModal<ShareRole>
|
||||
open={shareAccessOpen}
|
||||
resourceName="Workflow / Rail infrastructure analysis"
|
||||
members={shareMembers}
|
||||
roleOptions={shareRoleOptions}
|
||||
inviteEmail={shareEmail}
|
||||
inviteRole={shareRole}
|
||||
message={shareMessage}
|
||||
messageTone="accent"
|
||||
onInviteEmailChange={(email) => {
|
||||
setShareEmail(email);
|
||||
setShareMessage("");
|
||||
}}
|
||||
onInviteRoleChange={setShareRole}
|
||||
onInvite={inviteShareMember}
|
||||
onMemberRoleChange={(member, role) => {
|
||||
setShareMembers((current) => current.map((item) => item.id === member.id
|
||||
? { ...item, role, roleLabel: shareRoleOptions.find((option) => option.value === role)?.label }
|
||||
: item));
|
||||
}}
|
||||
onMemberRemove={(member) => setShareMembers((current) => current.filter((item) => item.id !== member.id))}
|
||||
onClose={() => setShareAccessOpen(false)}
|
||||
/>
|
||||
|
||||
<ShareLinkModal
|
||||
open={shareLinkOpen}
|
||||
link="https://bim.nodedc.ru/model/rail-hub?view=main"
|
||||
onCopy={() => undefined}
|
||||
onClose={() => setShareLinkOpen(false)}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={contextActionOpen}
|
||||
title="Размер"
|
||||
subtitle="BIM / contextual actions"
|
||||
size="sm"
|
||||
onClose={() => setContextActionOpen(false)}
|
||||
footer={<WindowFooterActions><Button onClick={() => setContextActionOpen(false)}>Закрыть</Button></WindowFooterActions>}
|
||||
>
|
||||
<div className="catalog-modal-action-list">
|
||||
<Button width="full" icon={<Icon name="activity" />}>Скрыть размер</Button>
|
||||
<Button width="full" variant="danger" icon={<Icon name="trash" />}>Удалить размер</Button>
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={historyOpen}
|
||||
title="История версий"
|
||||
subtitle="rail-hub-model.glb"
|
||||
size="lg"
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
>
|
||||
<div className="catalog-history-table" role="table" aria-label="История версий модели">
|
||||
<div className="catalog-history-row catalog-history-row--head" role="row">
|
||||
<span>Файл</span><span>Версия</span><span>Автор</span><span>Статус</span><span>Действия</span>
|
||||
</div>
|
||||
{[
|
||||
["rail-hub-model.glb", "v12 · текущая", "DC", "Модель готова"],
|
||||
["rail-hub-model.glb", "v11", "Maria", "Модель готова"],
|
||||
["rail-hub-model.glb", "v10", "Alex", "Ожидает подготовки"],
|
||||
].map((row) => (
|
||||
<div className="catalog-history-row" role="row" key={row[1]}>
|
||||
<strong>{row[0]}</strong><span>{row[1]}</span><span>{row[2]}</span><span>{row[3]}</span>
|
||||
<span className="catalog-history-actions">
|
||||
<IconButton label="Посмотреть"><Icon name="external" /></IconButton>
|
||||
<IconButton label="Скачать"><Icon name="download" /></IconButton>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={detailOpen}
|
||||
title="Комментарий к модели"
|
||||
subtitle="Опора №42 · рабочая detail-modal"
|
||||
size={detailExpanded ? "lg" : "md"}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button icon={<Icon name={detailExpanded ? "minimize" : "expand"} />} onClick={() => setDetailExpanded((current) => !current)}>
|
||||
{detailExpanded ? "Свернуть" : "Развернуть"}
|
||||
</Button>
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => setDetailOpen(false)}>Отмена</Button>
|
||||
<Button variant="accent" onClick={() => setDetailOpen(false)}>Сохранить</Button>
|
||||
</WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="catalog-detail-modal">
|
||||
<TextField label="Заголовок" value="Проверить узел крепления" readOnly />
|
||||
<TextAreaField label="Описание" value="Нужно сверить положение опоры с последней версией модели." readOnly />
|
||||
<SettingsCard title="Вложения" description="Файлы и изображения остаются BIM-domain content.">
|
||||
<Button icon={<Icon name="upload" />}>Выбрать файл</Button>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<ConfirmationModal
|
||||
open={confirmOpen}
|
||||
title="Удалить конфигурацию?"
|
||||
|
|
|
|||
|
|
@ -209,6 +209,56 @@ textarea {
|
|||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-modal-action-list,
|
||||
.catalog-detail-modal {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-history-table {
|
||||
display: grid;
|
||||
min-width: 46rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.catalog-history-row {
|
||||
display: grid;
|
||||
min-height: 3.5rem;
|
||||
grid-template-columns: minmax(12rem, 1.7fr) minmax(7rem, 0.7fr) minmax(6rem, 0.6fr) minmax(9rem, 0.9fr) 6rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.62rem 0.8rem;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.catalog-history-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.catalog-history-row--head {
|
||||
min-height: 2.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: 760;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.catalog-history-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.catalog-history-actions .nodedc-icon-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-basis: 2rem;
|
||||
}
|
||||
|
||||
.catalog-surface-stack .nodedc-glass {
|
||||
display: flex;
|
||||
min-height: 4rem;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,16 @@ Window — единая механика открытия modal и правой
|
|||
|
||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||
|
||||
## ShareAccessModal
|
||||
|
||||
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
||||
|
||||
Владелец отмечается `immutable`; отсутствие права управления задаётся `canManage`. Backdrop по умолчанию не закрывает access-modal, чтобы пользователь не потерял введённое приглашение.
|
||||
|
||||
## ShareLinkModal
|
||||
|
||||
Read-only link/copy окно из BIM Viewer. React-компонент использует общий Window, а Vanilla DOM consumer использует `createShareLinkController` вместе с `createModalController`. URL формируется сервисом модели; дизайн-система отвечает только за presentation, copy-state и ошибки адаптера.
|
||||
|
||||
## SegmentedControl
|
||||
|
||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||
|
|
@ -130,7 +140,7 @@ Pill navigation для верхней панели и компактного п
|
|||
|
||||
## Icon
|
||||
|
||||
`Icon` предоставляет только подтверждённый общий subset иконок. Каноническое имя описывает смысл (`close`, `expand`, `refresh`), а не конкретный путь SVG. Базовые размеры — `16`, `18` и `20 px`, stroke — `1.8`.
|
||||
`Icon` предоставляет только подтверждённый общий subset иконок. Каноническое имя описывает смысл (`close`, `expand`, `refresh`), а не конкретный путь SVG. Default glyph — `17 px`, stroke — `1.7`; внутри кнопок glyph оптически уменьшается до `90%`, но размеры surface/hit target остаются прежними.
|
||||
|
||||
Поверхность, круглая форма, hit target, active и disabled состояния принадлежат `IconButton`, `Button` или navigation item. Полный список находится в `registry/icons.json` и `docs/ICONS.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
## DOM consumer
|
||||
|
||||
CMS/BIM подключают те же tokens/core styles и `@nodedc/ui-dom`. `createApplicationWorkspaceController`, `createMediaSourceController`, `createFloatingLayer` и `createModalController` связывают стабильную DOM-анатомию с тем же поведением, не вводя React в приложение.
|
||||
CMS/BIM подключают те же tokens/core styles и `@nodedc/ui-dom`. `createApplicationWorkspaceController`, `createMediaSourceController`, `createShareLinkController`, `createFloatingLayer` и `createModalController` связывают стабильную DOM-анатомию с тем же поведением, не вводя React в приложение.
|
||||
|
||||
## Adapter boundary
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
| Контекст | Размер glyph | Hit target |
|
||||
| --- | ---: | ---: |
|
||||
| Плотная строка/label | `16px` | задаёт строка |
|
||||
| Обычная кнопка | `18px` | `46px` круг или кнопка с текстом |
|
||||
| Крупное оконное действие | `20px` | `46.72px` круг |
|
||||
| Плотная строка/label | `15–16px` | задаёт строка |
|
||||
| Обычная кнопка | `16–17px` | `46px` круг или кнопка с текстом |
|
||||
| Крупное оконное действие | `18px` | `46.72px` круг |
|
||||
|
||||
Базовый stroke — `1.8`. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности.
|
||||
Базовый stroke — `1.7`. Внутри кнопок glyph дополнительно получает optical scale `0.9`; hit target и наружная окружность не уменьшаются. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности.
|
||||
|
||||
## Группы
|
||||
|
||||
|
|
|
|||
|
|
@ -38,19 +38,22 @@ CMS подтверждает необходимость DOM-пакета без
|
|||
- NdcGlassSelect;
|
||||
- NdcGlassModal;
|
||||
- новый NDC Agent Inspector;
|
||||
- GraphsPanel workflow sharing modal и NdcUserAvatarStack;
|
||||
- styles, непосредственно обслуживающие эти элементы.
|
||||
|
||||
Все остальные legacy inspectors и старые plates не участвуют в принятии решений. Их будущее приведение к новому дизайну должно идти через эту библиотеку.
|
||||
|
||||
Размеры зафиксированы непосредственно по разрешённым исходникам: панель `390 px`, контент `330 px`, control row `154/14/162 px`, control height `46 px`, split-select `276/8/46 px`, section header `50 px` и radius `12 px`. Скриншоты от 10 июля 2026 года использованы как визуальная проверка, а не как замена исходному CSS.
|
||||
|
||||
Workflow sharing добавлен как отдельный разрешённый reference: modal `600 px`, compact stack до трёх avatar, roles `viewer/editor/admin`, immutable owner и приглашение через email. ACL endpoints, directory lookup и тексты backend-ошибок остаются Engine-domain.
|
||||
|
||||
## Task Manager / Ops
|
||||
|
||||
Используются только уже стандартизированные shared patterns и документы по dropdown/modal behavior. Известные legacy CustomMenu и screen-local wrappers не являются источником компонентов.
|
||||
|
||||
## BIM Viewer
|
||||
|
||||
Используется как ограничение архитектуры: общий UI должен работать без React. В baseline включены общие требования к toolbar, settings menu и glass select, но не BIM-domain controls.
|
||||
Используется как ограничение архитектуры: общий UI должен работать без React. Аудит `frontend/index.html`, `dcViewer.css`, `dcViewer.js`, `measurementModal.js` и `objectModal.js` подтвердил шесть modal-композиций: save form, delete confirmation, share link, contextual measurement/object actions, expandable comment detail и large version history. В библиотеку вынесены общая window-механика и share-link controller; BIM-таблицы, комментарии и операции над моделью остаются domain content.
|
||||
|
||||
## Аудит иконок
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ NODE.DC-приложения отличаются цветовой схемой,
|
|||
|
||||
Accent задаётся RGB tuple. On-accent вычисляется по luminance и не должен вручную прописываться белым или чёрным в отдельном компоненте.
|
||||
|
||||
Default accent текущего NODE.DC shell — `#ff2f92`. Каталог позволяет менять его через presets и `ColorField`, чтобы проверить любой consumer-цвет без создания новой темы или fork компонентов.
|
||||
|
||||
Semantic status не обязан совпадать с accent. Например, ошибка остаётся danger, даже если приложение использует красный брендовый accent.
|
||||
|
||||
## Область применения
|
||||
|
|
@ -53,4 +55,3 @@ Portal-компоненты рендерятся в `document.body`, поэто
|
|||
## Совместимость
|
||||
|
||||
Существующие `--nodedc-*` имена сохранены намеренно. Это уменьшает стоимость будущего подключения Launcher, Engine, Task Manager и BIM Viewer.
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,20 @@ Inline absolute dropdown внутри card/sidebar/sticky container являет
|
|||
7. body scroll блокируется;
|
||||
8. после закрытия focus возвращается на предыдущий trigger.
|
||||
|
||||
## Карта modal-паттернов
|
||||
|
||||
| Тип | Источник | Каноническая реализация |
|
||||
| --- | --- | --- |
|
||||
| Form/create/save | Launcher, CMS, BIM | `Window` + Field/Select + footer actions |
|
||||
| Destructive confirmation | Launcher, Engine, BIM | `ConfirmationModal` |
|
||||
| Workflow access sharing | новый Engine | `ShareAccessModal` |
|
||||
| Resource link sharing | BIM Viewer | `ShareLinkModal` / `createShareLinkController` |
|
||||
| Context actions | BIM measurement/object | `Window size="sm"` + application-owned actions |
|
||||
| Expandable detail | BIM comment workspace | `Window` или `ApplicationPanel`, domain content остаётся в BIM |
|
||||
| Large data/history | BIM version history | `Window size="lg"`, data table остаётся domain composition |
|
||||
|
||||
Новый тип появляется только если отличается поведением слоя или имеет устойчивую независимую anatomy. Разная таблица, форма или текст внутри Window не создают новую modal-систему.
|
||||
|
||||
## Side window / inspector
|
||||
|
||||
Side window использует ту же механику, но placement `end` и ограниченную ширину. Это канон для:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "nodedc-design-guideline",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodedc-design-guideline",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
|
|
@ -25,10 +25,10 @@
|
|||
},
|
||||
"apps/catalog": {
|
||||
"name": "@nodedc/ui-catalog",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.4.0",
|
||||
"@nodedc/ui-react": "0.4.0",
|
||||
"@nodedc/ui-core": "0.5.0",
|
||||
"@nodedc/ui-react": "0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
|
|
@ -1888,27 +1888,27 @@
|
|||
},
|
||||
"packages/tokens": {
|
||||
"name": "@nodedc/tokens",
|
||||
"version": "0.4.0"
|
||||
"version": "0.5.0"
|
||||
},
|
||||
"packages/ui-core": {
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "0.4.0"
|
||||
"@nodedc/tokens": "0.5.0"
|
||||
}
|
||||
},
|
||||
"packages/ui-dom": {
|
||||
"name": "@nodedc/ui-dom",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.4.0"
|
||||
"@nodedc/ui-core": "0.5.0"
|
||||
}
|
||||
},
|
||||
"packages/ui-react": {
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.4.0",
|
||||
"@nodedc/ui-core": "0.5.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "nodedc-design-guideline",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -130,6 +130,35 @@
|
|||
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
||||
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
|
||||
},
|
||||
{
|
||||
"id": "share-access-modal",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["ShareAccessModal"],
|
||||
"domContract": ["nodedc-share-access-window", "nodedc-share-access", "nodedc-share-access__summary", "nodedc-share-access__member-list"],
|
||||
"summary": "Engine-derived workflow access modal with compact member stack, role management and invite adapter slots.",
|
||||
"behavior": ["controlled members", "compact expandable member stack", "role change", "member removal", "invite state", "immutable owner", "backdrop close disabled"],
|
||||
"rules": [
|
||||
"The component owns access-modal geometry and interaction; the consumer owns ACL requests and directory lookup.",
|
||||
"Owner immutability and permission checks are supplied as member and canManage data.",
|
||||
"The 600 px Engine composition uses 46 px invite controls and collapses to one column on mobile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "share-link-modal",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["ShareLinkModal"],
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createShareLinkController", "createModalController"],
|
||||
"domContract": ["nodedc-window", "nodedc-field", "nodedc-button"],
|
||||
"summary": "BIM-derived read-only resource link modal with copy progress and framework-neutral copy controller.",
|
||||
"behavior": ["read-only link", "copy adapter", "copied state", "disabled empty state", "copy error adapter"],
|
||||
"rules": [
|
||||
"The resource service creates the URL; the modal only presents and copies it.",
|
||||
"BIM DOM consumers combine the share-link controller with the canonical modal controller and core classes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "segmented-control",
|
||||
"status": "baseline",
|
||||
|
|
@ -177,10 +206,10 @@
|
|||
"exports": ["Icon"],
|
||||
"domContract": ["svg using currentColor"],
|
||||
"summary": "Audited semantic icon subset shared by NODE.DC applications instead of product-local vendor imports.",
|
||||
"variants": ["16 px", "18 px", "20 px"],
|
||||
"variants": ["15 px", "17 px", "18 px"],
|
||||
"rules": [
|
||||
"Use semantic names from registry/icons.json.",
|
||||
"The containing component owns surface, hit target and state; the icon owns only the glyph.",
|
||||
"The containing component owns surface, hit target and state; the icon owns only the glyph and is optically scaled to 90% inside controls.",
|
||||
"Do not copy complete Lucide or Font Awesome catalogs into an application."
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"libraryVersion": "0.4.0",
|
||||
"libraryVersion": "0.5.0",
|
||||
"name": "NODE.DC Design System",
|
||||
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
|
||||
"packages": [
|
||||
|
|
|
|||
|
|
@ -45,12 +45,14 @@
|
|||
"id": "engine",
|
||||
"repository": "https://git.dcserve.ru/dctouch/NODEDC_ENGINE_INFRA.git",
|
||||
"auditedRevision": "d427272c7e",
|
||||
"role": "reference only for redesigned environment settings, glass controls and NDC agent inspector",
|
||||
"role": "reference only for redesigned environment settings, glass controls, NDC agent inspector and the approved workflow sharing modal",
|
||||
"approvedPaths": [
|
||||
"nodedc-source/src/components/environment",
|
||||
"nodedc-source/src/components/ui/NdcGlassChecker.tsx",
|
||||
"nodedc-source/src/components/ui/NdcGlassModal.tsx",
|
||||
"nodedc-source/src/components/ui/NdcGlassSelect.tsx",
|
||||
"nodedc-source/src/components/ui/NdcUserAvatarStack.tsx",
|
||||
"nodedc-source/src/components/header/GraphsPanel.tsx",
|
||||
"nodedc-source/src/driveinspector/NdcAgentInspector.tsx",
|
||||
"nodedc-source/src/style/menu.css",
|
||||
"nodedc-source/src/style/inspector.css"
|
||||
|
|
@ -85,7 +87,12 @@
|
|||
"auditedRevision": "1a78dd0",
|
||||
"role": "non-React integration reference and validation target",
|
||||
"approvedPaths": [
|
||||
"frontend/index.html",
|
||||
"frontend/dcViewer.css",
|
||||
"frontend/dcViewer.js",
|
||||
"frontend/src/ui/glassSelect.js",
|
||||
"frontend/src/ui/measurementModal.js",
|
||||
"frontend/src/ui/objectModal.js",
|
||||
"frontend/src/ui/settingsMenu.js",
|
||||
"frontend/src/ui/toolbar.js"
|
||||
],
|
||||
|
|
@ -104,4 +111,3 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue