922 lines
44 KiB
TypeScript
922 lines
44 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
||
import {
|
||
AdminNavigationPanel,
|
||
AppHeader,
|
||
ApplicationPanel,
|
||
ApplicationShell,
|
||
Button,
|
||
Checker,
|
||
ColorField,
|
||
ConfirmationModal,
|
||
ControlRow,
|
||
Dropdown,
|
||
FieldFrame,
|
||
GlassSurface,
|
||
HeaderAvatar,
|
||
HeaderNavigation,
|
||
HeaderProfile,
|
||
HeaderProfileButton,
|
||
HeaderWorkspace,
|
||
Icon,
|
||
IconButton,
|
||
Inspector,
|
||
MediaSourceField,
|
||
RangeControl,
|
||
Select,
|
||
ShareAccessModal,
|
||
ShareLinkModal,
|
||
SettingsCard,
|
||
StatusBadge,
|
||
Switch,
|
||
TextAreaField,
|
||
TextField,
|
||
useApplicationWorkspace,
|
||
Window,
|
||
WindowFooterActions,
|
||
type IconName,
|
||
type ShareAccessMember,
|
||
} from "@nodedc/ui-react";
|
||
|
||
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "icons";
|
||
|
||
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
|
||
{ 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 MaterialDraft = {
|
||
panelHex: string;
|
||
panelOpacity: number;
|
||
fieldHex: string;
|
||
fieldOpacity: number;
|
||
};
|
||
|
||
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
|
||
dark: {
|
||
panelHex: "#151517",
|
||
panelOpacity: 100,
|
||
fieldHex: "#2a2a2c",
|
||
fieldOpacity: 100,
|
||
},
|
||
light: {
|
||
panelHex: "#ffffff",
|
||
panelOpacity: 100,
|
||
fieldHex: "#ffffff",
|
||
fieldOpacity: 100,
|
||
},
|
||
};
|
||
|
||
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: "Временно недоступен" },
|
||
{ value: "disabled", label: "Отключён", description: "Запуск запрещён" },
|
||
] as const;
|
||
|
||
const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = {
|
||
foundation: {
|
||
eyebrow: "01 / FOUNDATION",
|
||
title: "Основа",
|
||
description: "Тема, нейтральные поверхности и неизменяемая геометрия приложения.",
|
||
icon: "panel",
|
||
},
|
||
controls: {
|
||
eyebrow: "02 / CONTROLS",
|
||
title: "Контролы",
|
||
description: "Кнопки, поля, выбор, Environment Settings и сворачиваемые секции нового Engine.",
|
||
icon: "sliders",
|
||
},
|
||
media: {
|
||
eyebrow: "03 / MEDIA & SETTINGS",
|
||
title: "Медиа и настройки",
|
||
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
|
||
icon: "image",
|
||
},
|
||
windows: {
|
||
eyebrow: "04 / WINDOWS",
|
||
title: "Окна",
|
||
description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.",
|
||
icon: "apps",
|
||
},
|
||
modals: {
|
||
eyebrow: "05 / MODALS",
|
||
title: "Модалки",
|
||
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
|
||
icon: "clipboard",
|
||
},
|
||
icons: {
|
||
eyebrow: "06 / ICONS",
|
||
title: "Иконки",
|
||
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
|
||
icon: "grid",
|
||
},
|
||
};
|
||
|
||
const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||
{
|
||
title: "Окно и слой",
|
||
note: "Launcher / Hub",
|
||
icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"],
|
||
},
|
||
{
|
||
title: "Навигация",
|
||
note: "Launcher / Engine / BIM",
|
||
icons: ["chevron-left", "chevron-right", "chevron-down", "grid", "list", "sliders", "search"],
|
||
},
|
||
{
|
||
title: "Редактирование",
|
||
note: "Launcher / SEO",
|
||
icons: ["save", "edit", "trash", "copy", "upload", "download", "external"],
|
||
},
|
||
{
|
||
title: "Состояние и доступ",
|
||
note: "Вся платформа",
|
||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
||
},
|
||
{
|
||
title: "Сущности",
|
||
note: "Общий словарь",
|
||
icons: ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"],
|
||
},
|
||
{
|
||
title: "Контент",
|
||
note: "SEO / BIM / Engine",
|
||
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
|
||
},
|
||
];
|
||
|
||
const iconLabels: Record<IconName, string> = {
|
||
activity: "Активность",
|
||
alert: "Предупреждение",
|
||
apps: "Приложения",
|
||
building: "Компания",
|
||
check: "Готово",
|
||
"chevron-down": "Раскрыть",
|
||
"chevron-left": "Назад",
|
||
"chevron-right": "Вперёд",
|
||
circle: "Маркер",
|
||
clipboard: "Список",
|
||
close: "Закрыть",
|
||
copy: "Копировать",
|
||
database: "База данных",
|
||
download: "Скачать",
|
||
edit: "Редактировать",
|
||
expand: "Развернуть",
|
||
external: "Открыть снаружи",
|
||
file: "Файл",
|
||
folder: "Папка",
|
||
globe: "Публичный контур",
|
||
grid: "Обзор",
|
||
image: "Изображение",
|
||
inbox: "Уведомления",
|
||
key: "Ключ",
|
||
list: "Список задач",
|
||
lock: "Закрыто",
|
||
mail: "Приглашение",
|
||
minimize: "Свернуть",
|
||
network: "Связи",
|
||
panel: "Панель",
|
||
plus: "Добавить",
|
||
profile: "Профиль",
|
||
refresh: "Обновить",
|
||
save: "Сохранить",
|
||
search: "Поиск",
|
||
settings: "Настройки",
|
||
shield: "Доступ",
|
||
sliders: "Параметры",
|
||
trash: "Удалить",
|
||
upload: "Загрузить",
|
||
users: "Участники",
|
||
video: "Видео",
|
||
};
|
||
|
||
function rgbFromHex(hex: string): RgbTuple | null {
|
||
const normalized = hex.trim().replace(/^#/, "");
|
||
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null;
|
||
return [
|
||
Number.parseInt(normalized.slice(0, 2), 16),
|
||
Number.parseInt(normalized.slice(2, 4), 16),
|
||
Number.parseInt(normalized.slice(4, 6), 16),
|
||
];
|
||
}
|
||
|
||
function Preview({ title, note, children, className }: {
|
||
title: string;
|
||
note?: string;
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<GlassSurface className={`catalog-preview ${className ?? ""}`} padding="lg">
|
||
<div className="catalog-preview__head">
|
||
<strong>{title}</strong>
|
||
{note ? <span>{note}</span> : null}
|
||
</div>
|
||
<div className="catalog-preview__body">{children}</div>
|
||
</GlassSurface>
|
||
);
|
||
}
|
||
|
||
export function CatalogApp() {
|
||
const [theme, setTheme] = useState<NodedcTheme>("dark");
|
||
const [accentHex, setAccentHex] = useState("#ff2f92");
|
||
const [materialByTheme, setMaterialByTheme] = useState<Record<NodedcTheme, MaterialDraft>>(() => ({
|
||
dark: { ...materialDefaults.dark },
|
||
light: { ...materialDefaults.light },
|
||
}));
|
||
const [environmentLayoutSaved, setEnvironmentLayoutSaved] = useState(false);
|
||
const workspace = useApplicationWorkspace<CatalogSection>();
|
||
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
||
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
|
||
const [checker, setChecker] = useState(true);
|
||
const [brightness, setBrightness] = useState(49);
|
||
const [glowDistance, setGlowDistance] = useState(105);
|
||
const [usePortColors, setUsePortColors] = useState(false);
|
||
const [connectionType, setConnectionType] = useState("spline");
|
||
const [lightColor, setLightColor] = useState("#ff2f92");
|
||
const [connectionColor, setConnectionColor] = useState("#404040");
|
||
const [fillColor, setFillColor] = useState("#ff6caf");
|
||
const [fillOpacity, setFillOpacity] = useState(100);
|
||
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
||
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
||
const [windowOpen, setWindowOpen] = useState(false);
|
||
const [sidePanelOpen, setSidePanelOpen] = 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("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]);
|
||
const material = materialByTheme[theme];
|
||
const panelMaterial = useMemo(() => rgbFromHex(material.panelHex) ?? rgbFromHex(materialDefaults[theme].panelHex)!, [material.panelHex, theme]);
|
||
const fieldMaterial = useMemo(() => rgbFromHex(material.fieldHex) ?? rgbFromHex(materialDefaults[theme].fieldHex)!, [material.fieldHex, theme]);
|
||
|
||
useEffect(() => {
|
||
applyNodedcTheme(document.documentElement, {
|
||
theme,
|
||
accent,
|
||
material: {
|
||
panel: panelMaterial,
|
||
panelOpacity: material.panelOpacity / 100,
|
||
field: fieldMaterial,
|
||
fieldOpacity: material.fieldOpacity / 100,
|
||
},
|
||
});
|
||
}, [accent, fieldMaterial, material.fieldOpacity, material.panelOpacity, panelMaterial, 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);
|
||
};
|
||
|
||
const closeGuideline = workspace.closeNavigation;
|
||
|
||
const updateMaterial = (changes: Partial<MaterialDraft>) => {
|
||
setMaterialByTheme((current) => ({
|
||
...current,
|
||
[theme]: { ...current[theme], ...changes },
|
||
}));
|
||
};
|
||
|
||
const saveEnvironmentLayout = () => {
|
||
const draft = {
|
||
theme,
|
||
accentHex,
|
||
material,
|
||
environment: {
|
||
lightColor,
|
||
brightness,
|
||
glowDistance,
|
||
connectionType,
|
||
connectionColor,
|
||
usePortColors,
|
||
fillColor,
|
||
fillOpacity,
|
||
strokeColor,
|
||
strokeOpacity,
|
||
},
|
||
};
|
||
try {
|
||
window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft));
|
||
} catch {
|
||
// The preview remains useful in restricted browser contexts even without local persistence.
|
||
}
|
||
setEnvironmentLayoutSaved(true);
|
||
};
|
||
|
||
const environmentSections = [
|
||
{
|
||
id: "application-material",
|
||
label: "Материал приложения",
|
||
description: "live draft",
|
||
tone: "accent" as const,
|
||
content: (
|
||
<>
|
||
<ControlRow label="Акцент">
|
||
<ColorField label="Акцент приложения" value={accentHex} onChange={setAccentHex} />
|
||
</ControlRow>
|
||
<ControlRow label="Плашки">
|
||
<ColorField label="Цвет плашек" value={material.panelHex} onChange={(panelHex) => updateMaterial({ panelHex })} />
|
||
</ControlRow>
|
||
<ControlRow label="Прозрачность плашки">
|
||
<RangeControl label="Прозрачность плашки" showLabel={false} value={material.panelOpacity} min={18} max={96} formatValue={(value) => `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} />
|
||
</ControlRow>
|
||
<ControlRow label="Поля ввода">
|
||
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
|
||
</ControlRow>
|
||
<ControlRow label="Прозрачность полей">
|
||
<RangeControl label="Прозрачность полей" showLabel={false} value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
|
||
</ControlRow>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
id: "illumination",
|
||
label: "Подсветка",
|
||
tone: "accent" as const,
|
||
content: (
|
||
<>
|
||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
id: "connections",
|
||
label: "Связи",
|
||
tone: "accent" as const,
|
||
content: (
|
||
<>
|
||
<ControlRow label="Связи по умолчанию">
|
||
<Select
|
||
variant="split"
|
||
label="Тип связи"
|
||
value={connectionType}
|
||
options={[
|
||
{ value: "spline", label: "Сплайн" },
|
||
{ value: "straight", label: "Прямая" },
|
||
{ value: "step", label: "Ступенчатая" },
|
||
]}
|
||
onChange={setConnectionType}
|
||
/>
|
||
</ControlRow>
|
||
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
|
||
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
|
||
<ControlRow label="Применить ко всем"><Button variant="primary" shape="pill" width="full">Применить</Button></ControlRow>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
id: "ports",
|
||
label: "Порты",
|
||
tone: "accent" as const,
|
||
content: (
|
||
<>
|
||
<ControlRow label="Заливка"><ColorField value={fillColor} onChange={setFillColor} /></ControlRow>
|
||
<RangeControl label="Прозрачность заливки" value={fillOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setFillOpacity} />
|
||
<ControlRow label="Обводка"><ColorField value={strokeColor} onChange={setStrokeColor} /></ControlRow>
|
||
<RangeControl label="Прозрачность обводки" value={strokeOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setStrokeOpacity} />
|
||
</>
|
||
),
|
||
},
|
||
];
|
||
|
||
const renderSectionContent = () => {
|
||
switch (activeSection) {
|
||
case "foundation":
|
||
return (
|
||
<div className="catalog-grid catalog-grid--foundation">
|
||
<Preview title="Тема приложения" note={theme === "dark" ? "Launcher / dark" : "SEO / light"}>
|
||
<div className="catalog-theme-row">
|
||
<Button variant={theme === "dark" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
|
||
<Button variant={theme === "light" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
|
||
</div>
|
||
<p className="catalog-preview__explanation">Две базовые темы фиксированы по эталонам: чёрная — Launcher/Hub, белая — SEO. Стекло используется только в модальных окнах.</p>
|
||
</Preview>
|
||
<Preview title="Поверхности приложения" note="Launcher / SEO">
|
||
<div className="catalog-surface-stack">
|
||
<GlassSurface padding="md">Основная поверхность</GlassSurface>
|
||
<GlassSurface tone="strong" padding="md">Контентное окно</GlassSurface>
|
||
<GlassSurface tone="soft" padding="md">Вложенная область</GlassSurface>
|
||
</div>
|
||
</Preview>
|
||
<Preview title="Каркас Launcher" note="fixed header / stage / panels" className="catalog-preview--wide">
|
||
<div className="catalog-shell-diagram" aria-label="Схема каркаса приложения">
|
||
<span className="catalog-shell-diagram__header">Header</span>
|
||
<span className="catalog-shell-diagram__nav">Navigation</span>
|
||
<span className="catalog-shell-diagram__content">Content window</span>
|
||
<span className="catalog-shell-diagram__stage">Stage</span>
|
||
</div>
|
||
</Preview>
|
||
</div>
|
||
);
|
||
case "controls":
|
||
return (
|
||
<div className="catalog-grid">
|
||
<Preview title="Действия" note="общая геометрия">
|
||
<div className="catalog-inline catalog-inline--wrap">
|
||
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
|
||
<Button icon={<Icon name="refresh" />}>Обновить</Button>
|
||
<Button variant="ghost" icon={<Icon name="external" />}>Открыть</Button>
|
||
<Button variant="danger" icon={<Icon name="trash" />}>Удалить</Button>
|
||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||
</div>
|
||
</Preview>
|
||
<Preview title="Поля" note="label / hint / control">
|
||
<div className="catalog-form">
|
||
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
|
||
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||
</div>
|
||
</Preview>
|
||
<Preview title="Selection" note="integrated / split">
|
||
<div className="catalog-form">
|
||
<FieldFrame label="Hub / integrated select">
|
||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||
</FieldFrame>
|
||
<FieldFrame label="Engine / split select">
|
||
<Select
|
||
variant="split"
|
||
label="Тип связи"
|
||
value={connectionType}
|
||
options={[
|
||
{ value: "spline", label: "Сплайн" },
|
||
{ value: "straight", label: "Прямая" },
|
||
{ value: "step", label: "Ступенчатая" },
|
||
]}
|
||
onChange={setConnectionType}
|
||
/>
|
||
</FieldFrame>
|
||
<Dropdown
|
||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
||
)}
|
||
>
|
||
{({ close }) => (
|
||
<>
|
||
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><Icon name="edit" /><span className="nodedc-dropdown-option__label">Переименовать</span></button>
|
||
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><Icon name="copy" /><span className="nodedc-dropdown-option__label">Дублировать</span></button>
|
||
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><Icon name="folder" /><span className="nodedc-dropdown-option__label">Архивировать</span></button>
|
||
</>
|
||
)}
|
||
</Dropdown>
|
||
</div>
|
||
</Preview>
|
||
<Preview title="Environment controls" note="Engine / select + accordion" className="catalog-preview--wide">
|
||
<div className="catalog-environment-demo">
|
||
<GlassSurface tone="strong" radius="panel" className="catalog-environment-card">
|
||
<div className="catalog-environment-title">
|
||
<div><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div>
|
||
<IconButton label="Сохранить текущий layout" onClick={saveEnvironmentLayout}><Icon name="save" /></IconButton>
|
||
</div>
|
||
{environmentLayoutSaved ? <p className="catalog-environment-draft" aria-live="polite">Layout сохранён локально. Сервер и MCP не используются.</p> : null}
|
||
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
|
||
</GlassSurface>
|
||
<p className="catalog-preview__explanation">Environment controls — единый референс Engine: palette, range, checker, split-select и раскрывающиеся sections. Верхняя секция меняет цвет плашек и полей приложения; сохраняется весь текущий layout одной кнопкой сверху.</p>
|
||
</div>
|
||
</Preview>
|
||
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
|
||
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
|
||
<div className="catalog-inline catalog-inline--wrap">
|
||
<StatusBadge>Черновик</StatusBadge>
|
||
<StatusBadge tone="success">Синхронизировано</StatusBadge>
|
||
<StatusBadge tone="warning">Нужна проверка</StatusBadge>
|
||
<StatusBadge tone="danger">Ошибка</StatusBadge>
|
||
</div>
|
||
</Preview>
|
||
</div>
|
||
);
|
||
case "media":
|
||
return (
|
||
<div className="catalog-grid catalog-grid--single">
|
||
<SettingsCard
|
||
eyebrow="CONTENT"
|
||
title="Видео-окно"
|
||
description="Окно демо-видео и подключённый медиа-файл. Хранилище и загрузка остаются в приложении; геометрия и переключение источника — в библиотеке."
|
||
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
|
||
>
|
||
<MediaSourceField
|
||
label="Видео / картинка"
|
||
source={mediaSource}
|
||
url={mediaUrl}
|
||
fileName={mediaFileName}
|
||
previewSrc={stageMediaSrc}
|
||
previewKind="video"
|
||
accept="video/mp4,.mp4"
|
||
path="stage.videoSrc → runtime media source"
|
||
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage."
|
||
error={mediaError}
|
||
onSourceChange={setMediaSource}
|
||
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">
|
||
<p className="catalog-preview__explanation">
|
||
`MediaSourceField` владеет пикселями, доступностью и состояниями file/URL. Конкретный проект передаёт upload-обработчик, storage-path и результат валидации — поэтому компонент можно подключить к CMS, Launcher или будущему приложению без копирования его вёрстки.
|
||
</p>
|
||
</Preview>
|
||
</div>
|
||
);
|
||
case "windows":
|
||
return (
|
||
<div className="catalog-grid catalog-grid--single">
|
||
<Preview title="Управляемые окна" note="center / modeless end">
|
||
<p className="catalog-preview__explanation">Обычное окно блокирует фон и удерживает фокус. Правый инспектор не затемняет, не блокирует stage и не превращается в modal.</p>
|
||
<div className="catalog-inline catalog-inline--wrap">
|
||
<Button variant="primary" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
|
||
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setSidePanelOpen(true)}>Открыть боковую панель</Button>
|
||
</div>
|
||
</Preview>
|
||
<Preview title="Оконные действия" note="круг `46 px`">
|
||
<div className="catalog-window-actions-demo">
|
||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||
</div>
|
||
</Preview>
|
||
</div>
|
||
);
|
||
case "modals":
|
||
return (
|
||
<div className="catalog-grid">
|
||
<Preview title="Создание" note="форма адаптируется по ширине">
|
||
<p className="catalog-preview__explanation">Поля переходят в одну колонку на узком viewport; footer остаётся видимым, прокручивается только тело окна.</p>
|
||
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(true)}>Создать проект</Button>
|
||
</Preview>
|
||
<Preview title="Подтверждение удаления" note="async-safe">
|
||
<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="primary" 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 "icons":
|
||
return (
|
||
<div className="catalog-icon-catalog">
|
||
<Preview title="Размеры и поверхности" note="16 / 18 / 20 px" className="catalog-preview--wide">
|
||
<div className="catalog-window-actions-demo">
|
||
<IconButton label="Добавить"><Icon name="plus" size={16} /></IconButton>
|
||
<IconButton label="Обновить"><Icon name="refresh" size={18} /></IconButton>
|
||
<IconButton label="Развернуть"><Icon name="expand" size={20} /></IconButton>
|
||
<IconButton label="Закрыть"><Icon name="close" size={18} /></IconButton>
|
||
</div>
|
||
<p className="catalog-preview__explanation">Иконка наследует `currentColor`; фон, размер кнопки и active-state задаёт компонент поверхности, а не SVG. Glyph оптически уменьшен на 18% без изменения hit target.</p>
|
||
</Preview>
|
||
{iconGroups.map((group) => (
|
||
<section className="catalog-icon-group" key={group.title}>
|
||
<header><strong>{group.title}</strong><span>{group.note}</span></header>
|
||
<div className="catalog-icon-grid">
|
||
{group.icons.map((name) => (
|
||
<div className="catalog-icon-tile" key={name}>
|
||
<span className="catalog-icon-tile__surface"><Icon name={name} size={20} /></span>
|
||
<span><strong>{iconLabels[name]}</strong><code>{name}</code></span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const activeDefinition = activeSection ? sectionDefinitions[activeSection] : null;
|
||
|
||
return (
|
||
<>
|
||
<ApplicationShell
|
||
className="catalog-app nodedc-ui-root"
|
||
style={{ "--catalog-accent": accentHex } as CSSProperties}
|
||
navigationOpen={guidelineOpen}
|
||
contentOpen={workspace.contentOpen}
|
||
contentExpanded={panelExpanded}
|
||
header={
|
||
<AppHeader
|
||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||
brandHref="/"
|
||
center={
|
||
<>
|
||
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl="/nodedc-mark.svg" />
|
||
<HeaderNavigation
|
||
label="Навигация приложения"
|
||
value={guidelineOpen ? "guideline" : undefined}
|
||
items={[{ value: "guideline", label: "Guideline" }]}
|
||
onChange={() => guidelineOpen ? closeGuideline() : workspace.openNavigation()}
|
||
/>
|
||
</>
|
||
}
|
||
right={
|
||
<HeaderProfile>
|
||
<IconButton label="Уведомления"><Icon name="inbox" size={20} /></IconButton>
|
||
<HeaderProfileButton>Профиль</HeaderProfileButton>
|
||
<HeaderAvatar label="DC" />
|
||
</HeaderProfile>
|
||
}
|
||
/>
|
||
}
|
||
stage={
|
||
<section className="catalog-launcher-stage">
|
||
<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">
|
||
<span>NODE.DC</span>
|
||
<strong>DESIGN<br />GUIDELINE</strong>
|
||
<p>Общий форм-фактор приложений, компоненты и оконная механика платформы.</p>
|
||
</div>
|
||
</section>
|
||
}
|
||
navigation={
|
||
<AdminNavigationPanel
|
||
eyebrow="NODE.DC"
|
||
title="Visual Guideline"
|
||
closeLabel="Закрыть Guideline"
|
||
navigationLabel="Разделы Guideline"
|
||
onClose={closeGuideline}
|
||
items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
|
||
id,
|
||
label: item.title,
|
||
icon: <Icon name={item.icon} />,
|
||
}))}
|
||
activeId={activeSection ?? undefined}
|
||
onItemChange={openSection}
|
||
footer={
|
||
<>
|
||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
|
||
<span>Design System 0.5.1</span>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
content={activeDefinition ? (
|
||
<ApplicationPanel
|
||
key={activeSection}
|
||
eyebrow={activeDefinition.eyebrow}
|
||
title={activeDefinition.title}
|
||
description={activeDefinition.description}
|
||
expanded={panelExpanded}
|
||
onExpandedChange={workspace.setContentExpanded}
|
||
onClose={workspace.closeView}
|
||
>
|
||
<div className="catalog-panel-content">{renderSectionContent()}</div>
|
||
</ApplicationPanel>
|
||
) : null}
|
||
/>
|
||
|
||
<Window
|
||
open={windowOpen}
|
||
title="Настройки сервиса"
|
||
subtitle="Launcher / CMS / SEO"
|
||
onClose={() => setWindowOpen(false)}
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button>
|
||
<WindowFooterActions><Button variant="primary" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
|
||
</>
|
||
}
|
||
>
|
||
<div className="catalog-form">
|
||
<TextField label="Название" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
|
||
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
|
||
<Checker checked={checker} label="Сервис активен" onChange={setChecker} />
|
||
</div>
|
||
</Window>
|
||
|
||
<Window
|
||
open={sidePanelOpen}
|
||
title="Боковая панель"
|
||
subtitle="modeless / shell contract"
|
||
placement="end"
|
||
onClose={() => setSidePanelOpen(false)}
|
||
>
|
||
<div className="catalog-form">
|
||
<TextField label="Название панели" value="Параметры рабочей области" readOnly />
|
||
<p className="catalog-preview__explanation">Боковая панель демонстрирует только layer-механику: она modeless, не затемняет stage и не забирает фокус. Environment controls живут в разделе «Контролы».</p>
|
||
</div>
|
||
</Window>
|
||
|
||
<Window
|
||
open={createModalOpen}
|
||
title="Создать проект"
|
||
subtitle="Адаптивная форма"
|
||
size="sm"
|
||
onClose={() => setCreateModalOpen(false)}
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Отмена</Button>
|
||
<WindowFooterActions><Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(false)}>Создать</Button></WindowFooterActions>
|
||
</>
|
||
}
|
||
>
|
||
<div className="catalog-form">
|
||
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
|
||
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
|
||
</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="primary" 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="Удалить конфигурацию?"
|
||
description={<>Конфигурация будет удалена. Это действие нельзя отменить.</>}
|
||
confirmLabel="Удалить"
|
||
danger
|
||
onClose={() => setConfirmOpen(false)}
|
||
onConfirm={() => setConfirmOpen(false)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|