Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/CatalogApp.tsx
T

2460 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
import {
ActivityIndicator,
ProgressBar,
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
ApplicationSidePanel,
Button,
Checker,
ColorField,
ConfirmationModal,
ControlRow,
Dropdown,
DragDropRoot,
DraggableItem,
DropZone,
FieldFrame,
GlassSurface,
GlassMaterialSurface,
HeaderNavigation,
HeaderProfile,
HeaderWorkspace,
Icon,
IconButton,
Inspector,
InspectorSelectField,
MediaSourceField,
RangeControl,
ResourceRow,
ResourceList,
SegmentedControl,
Select,
ShareAccessModal,
ShareLinkModal,
SplitPane,
SortableItem,
SortableScope,
SettingsCard,
Switch,
TextAreaField,
TextField,
ToastCard,
ToastStack,
Toolbar,
UserProfileMenu,
useApplicationWorkspace,
Window,
WindowFooterActions,
WorkspaceWindow,
type IconName,
type ShareAccessMember,
type ToastItem,
type ToastTone,
type ToolbarPlacement,
type WorkspaceWindowRect,
} from "@nodedc/ui-react";
import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js";
import {
applicationIdFromView,
applicationPageIdFromView,
applicationPageViewId,
applicationViewId,
type ApplicationDraftSaveState,
type ApplicationManifestV01,
type ApplicationSummary,
type DesignProfileSummary,
type DesignProfileStatus,
} from "./applicationManifest.js";
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
import {
mapDesignFragmentForLayout,
mapDesignFragmentFromLayout,
mapDesignOverridesFromResolved,
mapDesignProfileKey,
resolveMapDesignLayout,
type MaterialDraft,
type StoredLayout,
} from "./designProfile.js";
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
type StudioContext = "visual" | "pages" | "applications";
type StudioView = CatalogSection | `page-template:${string}` | `application:${string}` | `application:${string}/page:${string}`;
type DesignProfileSaveScope = { kind: "global" } | { kind: "page"; templateId: "map"; templateVersion: string };
type DesignProfileDocument = {
id: string;
name: string;
version: string;
status: DesignProfileStatus;
layout: StoredLayout;
timestamps: { updatedAt: string; publishedAt?: string };
};
const pageTemplateViewId = (id: string) => `page-template:${id}` as const;
const pageTemplateIdFromView = (view: string | null) => view?.startsWith("page-template:") ? view.slice("page-template:".length) : null;
const designProfileDocumentPath = (reference: { id: string; version: string; status: DesignProfileStatus }) => (
reference.status === "published"
? `/api/design-profiles/${reference.id}/versions/${reference.version}`
: `/api/design-profiles/${reference.id}`
);
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" },
];
interface FoundrySessionProfile {
user: {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
initials: string;
} | null;
profileUrl: string | null;
access?: {
role: "admin" | "user";
};
}
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
dark: {
panelHex: "#151517",
panelOpacity: 100,
fieldHex: "#2a2a2c",
fieldOpacity: 100,
nestedHex: "#0b0b0d",
},
light: {
panelHex: "#ffffff",
panelOpacity: 100,
fieldHex: "#ffffff",
fieldOpacity: 100,
nestedHex: "#f4f4f4",
},
};
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 }> = {
controls: {
eyebrow: "01 / CONTROLS",
title: "Контролы",
description: "Кнопки, поля, выбор, оконные действия и вызываемый Inspector нового Engine.",
icon: "sliders",
},
media: {
eyebrow: "02 / MEDIA & SETTINGS",
title: "Медиа и настройки",
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
icon: "image",
},
glass: {
eyebrow: "03 / GLASSY MATERIAL",
title: "Glassy material",
description: "Единый материал только для модальных окон и перемещаемого Inspector.",
icon: "apps",
},
status: {
eyebrow: "04 / STATUS NOTIFICATIONS",
title: "Статусы",
description: "Канонические стеклянные уведомления о сохранении, обновлении и ошибках — стек снизу справа.",
icon: "activity",
},
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", "eye", "eye-off"],
},
{
title: "Сущности",
note: "Общий словарь",
icons: ["profile", "users", "building", "globe", "target", "database", "network", "inbox", "mail"],
},
{
title: "Контент",
note: "SEO / BIM / Engine",
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
},
];
const iconLabels: Record<IconName, string> = {
activity: "Активность",
alert: "Предупреждение",
apps: "Приложения",
building: "Компания",
camera: "Камера",
check: "Готово",
"chevron-down": "Раскрыть",
"chevron-left": "Назад",
"chevron-right": "Вперёд",
circle: "Маркер",
clipboard: "Список",
close: "Закрыть",
copy: "Копировать",
database: "База данных",
download: "Скачать",
edit: "Редактировать",
eye: "Показать",
"eye-off": "Скрыть",
expand: "Развернуть",
external: "Открыть снаружи",
file: "Файл",
folder: "Папка",
globe: "Публичный контур",
grid: "Обзор",
image: "Изображение",
inbox: "Уведомления",
key: "Ключ",
list: "Список задач",
lock: "Закрыто",
mail: "Приглашение",
minimize: "Свернуть",
network: "Связи",
panel: "Панель",
plan: "План",
play: "Воспроизвести",
target: "Таргеты",
plus: "Добавить",
profile: "Профиль",
refresh: "Обновить",
save: "Сохранить",
search: "Поиск",
settings: "Настройки",
shield: "Доступ",
sliders: "Параметры",
stop: "Остановить",
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 [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const [studioContext, setStudioContext] = useState<StudioContext>("visual");
const [applicationSummaries, setApplicationSummaries] = useState<ApplicationSummary[]>([]);
const [designProfiles, setDesignProfiles] = useState<DesignProfileSummary[]>([]);
const [activeDesignProfileLayout, setActiveDesignProfileLayout] = useState<StoredLayout | null>(null);
const [applicationDesignProfileLayout, setApplicationDesignProfileLayout] = useState<StoredLayout | null>(null);
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
const [applicationError, setApplicationError] = useState("");
const [toasts, setToasts] = useState<ToastItem[]>([]);
const toastSequenceRef = useRef(0);
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
const [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
const applicationMapPreviewRef = useRef<MapFixturePreviewHandle>(null);
const [mapSettingsPanelHost, setMapSettingsPanelHost] = useState<HTMLDivElement | null>(null);
const [mapHeaderActionsHost, setMapHeaderActionsHost] = useState<HTMLDivElement | null>(null);
const [mapSettingsPanelOpen, setMapSettingsPanelOpen] = useState(false);
const [createModuleOpen, setCreateModuleOpen] = useState(false);
const [createModuleName, setCreateModuleName] = useState("");
const [createModuleSlug, setCreateModuleSlug] = useState("");
const [createModuleDescription, setCreateModuleDescription] = useState("");
const [deleteModuleOpen, setDeleteModuleOpen] = useState(false);
const [pageRemovalId, setPageRemovalId] = useState<string | null>(null);
const [applicationMode, setApplicationMode] = useState<"edit" | "preview">("edit");
const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false);
const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save");
const [designProfileSaveScope, setDesignProfileSaveScope] = useState<DesignProfileSaveScope>({ kind: "global" });
const [designProfileName, setDesignProfileName] = useState("");
const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle");
const [activeDesignProfileId, setActiveDesignProfileId] = useState("default");
const workspace = useApplicationWorkspace<StudioView>();
const { navigationOpen: guidelineOpen, activeView, contentExpanded: panelExpanded } = workspace;
const activeSection = studioContext === "visual" && activeView && !activeView.startsWith("application:")
? activeView as CatalogSection
: null;
const activeApplicationId = applicationIdFromView(activeView);
const activeApplicationPageId = applicationPageIdFromView(activeView);
const activePageTemplateId = pageTemplateIdFromView(activeView);
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
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 [inspectorOpen, setInspectorOpen] = useState(false);
const workspaceWindowDemoRef = useRef<HTMLDivElement>(null);
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
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("");
// Match the persisted production design-profile default from first paint.
// This prevents the packaged pink sample clip from flashing before
// /api/layout returns the same saved media configuration.
const [mediaFileName, setMediaFileName] = useState("possible shapes.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/uploads/1783715822132-possible-shapes.mp4");
const [mediaError, setMediaError] = useState("");
const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null);
const pendingMediaFileRef = useRef<File | null>(null);
const [logoSource, setLogoSource] = useState<"file" | "url">("file");
const [logoUrl, setLogoUrl] = useState("");
const [logoFileName, setLogoFileName] = useState("nodedc-mark.svg");
const [logoFileSrc, setLogoFileSrc] = useState("/nodedc-mark.svg");
const [logoError, setLogoError] = useState("");
const logoObjectUrlRef = useRef<string | null>(null);
const pendingLogoFileRef = useRef<File | null>(null);
const [glassMaterial, setGlassMaterial] = useState<GlassMaterialSettings>({ ...defaultGlassMaterial });
const [faviconFileName, setFaviconFileName] = useState("icon-adaptive.svg");
const [faviconError, setFaviconError] = useState("");
const [generatedFavicons, setGeneratedFavicons] = useState<GeneratedFavicon[]>([]);
const [faviconAssets, setFaviconAssets] = useState<FaviconAssetUrls>({
ico: "/favicon/favicon.ico",
apple: "/favicon/apple-touch-icon.png",
icon192: "/favicon/icon-192.png",
icon512: "/favicon/icon-512.png",
});
const pendingFaviconIcoRef = useRef<Blob | null>(null);
const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
const [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117");
const [toolbarOutline, setToolbarOutline] = useState("#1c1c1c");
const [toolbarMinSize, setToolbarMinSize] = useState(25);
const [toolbarMaxSize, setToolbarMaxSize] = useState(87);
const [toolbarLensCount, setToolbarLensCount] = useState(5);
const [toolbarAutoHide, setToolbarAutoHide] = useState(false);
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 dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((item) => item.id !== id));
}, []);
const pushToast = useCallback((tone: ToastTone, title: string, description?: string, durationMs?: number | null) => {
toastSequenceRef.current += 1;
const id = `foundry-toast-${toastSequenceRef.current}`;
setToasts((current) => [...current.slice(-3), { id, tone, title, description, durationMs }]);
return id;
}, []);
const updateToast = useCallback((id: string, patch: Partial<Omit<ToastItem, "id">>) => {
setToasts((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item));
}, []);
const applyDesignProfileLayout = useCallback((stored: StoredLayout) => {
if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme);
if (stored.accentHex) setAccentHex(stored.accentHex);
if (stored.materialByTheme) setMaterialByTheme((current) => ({
dark: { ...current.dark, ...stored.materialByTheme?.dark },
light: { ...current.light, ...stored.materialByTheme?.light },
}));
if (stored.environment) {
if (stored.environment.lightColor) setLightColor(stored.environment.lightColor);
if (typeof stored.environment.brightness === "number") setBrightness(stored.environment.brightness);
if (typeof stored.environment.glowDistance === "number") setGlowDistance(stored.environment.glowDistance);
if (stored.environment.connectionType) setConnectionType(stored.environment.connectionType);
if (stored.environment.connectionColor) setConnectionColor(stored.environment.connectionColor);
if (typeof stored.environment.usePortColors === "boolean") setUsePortColors(stored.environment.usePortColors);
if (stored.environment.fillColor) setFillColor(stored.environment.fillColor);
if (typeof stored.environment.fillOpacity === "number") setFillOpacity(stored.environment.fillOpacity);
if (stored.environment.strokeColor) setStrokeColor(stored.environment.strokeColor);
if (typeof stored.environment.strokeOpacity === "number") setStrokeOpacity(stored.environment.strokeOpacity);
}
if (stored.media) {
if (stored.media.source === "file" || stored.media.source === "url") setMediaSource(stored.media.source);
if (typeof stored.media.url === "string") setMediaUrl(stored.media.url);
if (stored.media.fileName) setMediaFileName(stored.media.fileName);
if (stored.media.fileSrc) setFileMediaSrc(stored.media.fileSrc);
if (typeof stored.media.visible === "boolean") setMediaVisible(stored.media.visible);
if (stored.media.logoSource === "file" || stored.media.logoSource === "url") setLogoSource(stored.media.logoSource);
if (typeof stored.media.logoUrl === "string") setLogoUrl(stored.media.logoUrl);
if (stored.media.logoFileName) setLogoFileName(stored.media.logoFileName);
if (stored.media.logoFileSrc) setLogoFileSrc(stored.media.logoFileSrc);
if (stored.media.faviconFileName) setFaviconFileName(stored.media.faviconFileName);
if (stored.media.faviconAssets) setFaviconAssets(stored.media.faviconAssets);
}
if (stored.glass) setGlassMaterial({ ...defaultGlassMaterial, ...stored.glass });
if (stored.toolbar?.placement) setToolbarPlacement(stored.toolbar.placement);
if (stored.toolbar?.background) setToolbarBg(stored.toolbar.background);
if (stored.toolbar?.border) setToolbarBorder(stored.toolbar.border);
if (stored.toolbar?.outline) setToolbarOutline(stored.toolbar.outline);
if (typeof stored.toolbar?.minSize === "number") setToolbarMinSize(stored.toolbar.minSize);
if (typeof stored.toolbar?.maxSize === "number") setToolbarMaxSize(stored.toolbar.maxSize);
if (typeof stored.toolbar?.lensCount === "number") setToolbarLensCount(stored.toolbar.lensCount);
if (typeof stored.toolbar?.autoHide === "boolean") setToolbarAutoHide(stored.toolbar.autoHide);
}, []);
const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl)
? mediaUrl
: null;
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc;
const externalLogoSrc = /^(https?:)?\/\//i.test(logoUrl) && /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(logoUrl)
? logoUrl
: null;
const headerMarkSrc = logoSource === "url" && externalLogoSrc ? externalLogoSrc : logoFileSrc;
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,
},
});
document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex);
}, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]);
useEffect(() => {
applyGlassMaterial(document.documentElement, glassMaterial);
}, [glassMaterial]);
useEffect(() => {
applyFaviconAssets(faviconAssets);
}, [faviconAssets]);
useEffect(() => {
let active = true;
fetch("/api/session/profile", { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<FoundrySessionProfile> : null)
.then((profile) => { if (active) setSessionProfile(profile); })
.catch(() => { if (active) setSessionProfile(null); });
return () => { active = false; };
}, []);
const isFoundryAdmin = sessionProfile?.access?.role === "admin";
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("layout_load_failed");
return await response.json() as StoredLayout | null;
})
.then((stored) => {
if (!active || !stored) return;
if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme);
if (stored.accentHex) setAccentHex(stored.accentHex);
if (stored.materialByTheme?.dark && stored.materialByTheme?.light) setMaterialByTheme(stored.materialByTheme);
const environment = stored.environment;
if (environment) {
if (environment.lightColor) setLightColor(environment.lightColor);
if (typeof environment.brightness === "number") setBrightness(environment.brightness);
if (typeof environment.glowDistance === "number") setGlowDistance(environment.glowDistance);
if (environment.connectionType) setConnectionType(environment.connectionType);
if (environment.connectionColor) setConnectionColor(environment.connectionColor);
if (typeof environment.usePortColors === "boolean") setUsePortColors(environment.usePortColors);
if (environment.fillColor) setFillColor(environment.fillColor);
if (typeof environment.fillOpacity === "number") setFillOpacity(environment.fillOpacity);
if (environment.strokeColor) setStrokeColor(environment.strokeColor);
if (typeof environment.strokeOpacity === "number") setStrokeOpacity(environment.strokeOpacity);
}
const media = stored.media;
if (media) {
if (media.source === "file" || media.source === "url") setMediaSource(media.source);
if (typeof media.url === "string") setMediaUrl(media.url);
if (media.fileName) setMediaFileName(media.fileName);
if (media.fileSrc) setFileMediaSrc(media.fileSrc);
if (typeof media.visible === "boolean") setMediaVisible(media.visible);
if (media.logoSource === "file" || media.logoSource === "url") setLogoSource(media.logoSource);
if (typeof media.logoUrl === "string") setLogoUrl(media.logoUrl);
if (media.logoFileName) setLogoFileName(media.logoFileName);
if (media.logoFileSrc) setLogoFileSrc(media.logoFileSrc);
if (media.faviconFileName) setFaviconFileName(media.faviconFileName);
if (media.faviconAssets) setFaviconAssets(media.faviconAssets);
}
if (stored.glass) {
const legacyDefaultOpacity = stored.glass.version !== 4 && stored.glass.tintOpacity === 54;
setGlassMaterial({
...defaultGlassMaterial,
...stored.glass,
version: 4,
tintOpacity: legacyDefaultOpacity ? defaultGlassMaterial.tintOpacity : stored.glass.tintOpacity,
});
}
const toolbar = stored.toolbar;
if (toolbar) {
if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement);
if (toolbar.background) setToolbarBg(toolbar.background);
if (toolbar.border) setToolbarBorder(toolbar.border);
if (toolbar.outline) setToolbarOutline(toolbar.outline);
if (typeof toolbar.minSize === "number") setToolbarMinSize(toolbar.minSize);
if (typeof toolbar.maxSize === "number") setToolbarMaxSize(toolbar.maxSize);
if (typeof toolbar.lensCount === "number") setToolbarLensCount(toolbar.lensCount);
if (typeof toolbar.autoHide === "boolean") setToolbarAutoHide(toolbar.autoHide);
}
setLayoutSaveState("saved");
})
.catch(() => {
if (active) setLayoutSaveState("error");
})
.finally(() => {
if (active) setLayoutSaveState((current) => current === "loading" ? "idle" : current);
});
return () => { active = false; };
}, []);
useEffect(() => {
let active = true;
fetch("/api/page-layouts/map", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("map_page_layout_load_failed");
return await response.json() as MapPageLayout | null;
})
.then((layout) => {
if (!active) return;
setMapTemplateLayout(layout);
setMapTemplateSaveState(layout ? "saved" : "idle");
})
.catch(() => {
if (active) setMapTemplateSaveState("error");
});
return () => { active = false; };
}, []);
useEffect(() => {
let active = true;
fetch("/api/design-profiles", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("design_profiles_load_failed");
return await response.json() as DesignProfileSummary[];
})
.then((profiles) => {
if (!active) return;
setDesignProfiles(profiles);
if (profiles.length > 0 && !profiles.some((profile) => profile.id === activeDesignProfileId)) setActiveDesignProfileId(profiles[0].id);
})
.catch(() => {
if (active) setApplicationError("Не удалось загрузить Design Profiles.");
});
return () => { active = false; };
}, []);
useEffect(() => {
let active = true;
fetch(`/api/design-profiles/${activeDesignProfileId}`, { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("design_profile_load_failed");
return await response.json() as DesignProfileDocument;
})
.then((profile) => {
if (!active) return;
const stored = profile.layout;
setActiveDesignProfileLayout(stored);
applyDesignProfileLayout(stored);
setLayoutSaveState("saved");
})
.catch(() => {
if (active) setLayoutSaveState("error");
});
return () => { active = false; };
}, [activeDesignProfileId, applyDesignProfileLayout]);
useEffect(() => {
let active = true;
fetch("/api/applications", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("applications_load_failed");
return await response.json() as ApplicationSummary[];
})
.then((summaries) => {
if (active) setApplicationSummaries(summaries);
})
.catch(() => {
if (active) setApplicationError("Не удалось загрузить Application Drafts.");
});
return () => { active = false; };
}, []);
useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
generatedFavicons.forEach((item) => URL.revokeObjectURL(item.previewUrl));
}, [generatedFavicons]);
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;
pendingMediaFileRef.current = file;
setFileMediaSrc(objectUrl);
setMediaFileName(file.name);
setMediaSource("file");
setMediaVisible(true);
setMediaError("");
};
const handleLogoFile = (file?: File) => {
if (!file) return;
const isImage = /^(image\/(png|jpeg|gif|webp))$/i.test(file.type) || /\.(png|jpe?g|gif|webp)$/i.test(file.name);
if (!isImage) {
setLogoError("Для знака нужен PNG, JPG, GIF или WEBP.");
return;
}
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
const objectUrl = URL.createObjectURL(file);
logoObjectUrlRef.current = objectUrl;
pendingLogoFileRef.current = file;
setLogoFileSrc(objectUrl);
setLogoFileName(file.name);
setLogoSource("file");
setLogoError("");
};
const handleFaviconFile = async (file?: File) => {
if (!file) return;
const isImage = /^(image\/(png|jpeg|webp))$/i.test(file.type) || /\.(png|jpe?g|webp)$/i.test(file.name);
if (!isImage) {
setFaviconError("Для генератора нужен PNG, JPG или WEBP.");
return;
}
try {
const next = await generateFavicons(file);
setGeneratedFavicons((current) => {
current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
return next;
});
pendingFaviconIcoRef.current = await createIcoBlob(next);
setFaviconFileName(file.name);
setFaviconError("");
} catch {
setFaviconError("Не удалось прочитать исходное изображение.");
}
};
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) => {
setStudioContext("visual");
workspace.openView(next as CatalogSection);
};
const openDesignProfile = (id: string) => {
setActiveDesignProfileId(id);
};
const openApplicationDraft = async (id: string) => {
setStudioContext("applications");
setApplicationSaveState("loading");
setApplicationError("");
workspace.openView(applicationViewId(id));
try {
const response = await fetch(`/api/applications/${id}`, { cache: "no-store" });
if (!response.ok) throw new Error("application_load_failed");
const manifest = await response.json() as ApplicationManifestV01;
const profileResponse = await fetch(designProfileDocumentPath(manifest.designProfile), { cache: "no-store" });
if (!profileResponse.ok) throw new Error("application_design_profile_load_failed");
const profile = await profileResponse.json() as DesignProfileDocument;
setApplicationDraft(manifest);
setApplicationDesignProfileLayout(profile.layout);
applyDesignProfileLayout(profile.layout);
setApplicationSaveState("saved");
} catch {
setApplicationDraft(null);
setApplicationDesignProfileLayout(null);
setApplicationSaveState("error");
setApplicationError("Не удалось открыть Application Draft.");
}
};
const selectApplicationDesignProfile = async (reference: string) => {
if (!applicationDraft) return;
const [profileId, version, statusValue] = reference.split("@");
const profile = designProfiles.find((item) => item.id === profileId);
if (!profile) return;
const status: DesignProfileStatus = statusValue === "published" ? "published" : "draft";
const release = status === "published" ? profile.versions.find((item) => item.version === version) : undefined;
const profileTheme = release?.theme ?? profile.theme;
setApplicationSaveState("loading");
setApplicationError("");
try {
const response = await fetch(designProfileDocumentPath({ id: profile.id, version, status }), { cache: "no-store" });
if (!response.ok) throw new Error("design_profile_load_failed");
const document = await response.json() as DesignProfileDocument;
setApplicationDesignProfileLayout(document.layout);
applyDesignProfileLayout(document.layout);
updateApplicationDraft((current) => ({
...current,
designProfile: { id: profile.id, version, status, theme: profileTheme },
}));
} catch {
setApplicationSaveState("error");
setApplicationError("Не удалось применить выбранный Design Profile.");
}
};
const openPageTemplate = (id: string) => {
setStudioContext("pages");
workspace.openView(pageTemplateViewId(id));
};
const createApplicationDraft = async (template?: PageTemplateDefinition, metadata?: { name: string; slug: string; description: string }) => {
setStudioContext("applications");
setApplicationSaveState("saving");
setApplicationError("");
try {
const response = await fetch("/api/applications", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
templateId: template?.id,
templateVersion: template?.version,
name: metadata?.name || (template ? `NODE.DC ${template.title} Demo` : "Новый модуль"),
slug: metadata?.slug || (template ? `nodedc-${template.id}-demo` : "new-module"),
description: metadata?.description,
theme,
}),
});
if (!response.ok) throw new Error("application_create_failed");
const manifest = await response.json() as ApplicationManifestV01;
const profileResponse = await fetch(designProfileDocumentPath(manifest.designProfile), { cache: "no-store" });
if (!profileResponse.ok) throw new Error("application_design_profile_load_failed");
const profile = await profileResponse.json() as DesignProfileDocument;
setApplicationDraft(manifest);
setApplicationDesignProfileLayout(profile.layout);
applyDesignProfileLayout(profile.layout);
setApplicationSummaries((current) => [
{
id: manifest.id,
name: manifest.metadata.name,
slug: manifest.metadata.slug,
status: manifest.status,
version: manifest.version,
theme: manifest.designProfile.theme,
pageCount: manifest.pages.length,
updatedAt: manifest.timestamps.updatedAt,
},
...current.filter((item) => item.id !== manifest.id),
]);
workspace.openView(applicationViewId(manifest.id));
setApplicationSaveState("saved");
setCreateModuleOpen(false);
setCreateModuleName("");
setCreateModuleSlug("");
setCreateModuleDescription("");
} catch {
setApplicationSaveState("error");
setApplicationError(`Не удалось создать ${template ? `draft из шаблона ${template.title}` : "модуль"}.`);
}
};
const deleteApplicationDraft = async () => {
if (!applicationDraft) return;
const response = await fetch(`/api/applications/${applicationDraft.id}`, { method: "DELETE" });
if (!response.ok) throw new Error("application_delete_failed");
setApplicationSummaries((current) => current.filter((item) => item.id !== applicationDraft.id));
setApplicationDraft(null);
setDeleteModuleOpen(false);
workspace.closeView();
};
const updateApplicationDraft = (update: (current: ApplicationManifestV01) => ApplicationManifestV01) => {
setApplicationDraft((current) => current ? update(current) : current);
setApplicationSaveState("idle");
setApplicationError("");
};
const saveApplicationDraft = async () => {
if (!applicationDraft) return;
const toastId = pushToast("loading", "Сохраняем Application", applicationDraft.metadata.name, null);
const activeMapLayout = activeApplicationPageId ? applicationMapPreviewRef.current?.getLayout() : null;
const activePage = activeApplicationPageId
? applicationDraft.pages.find((page) => page.id === activeApplicationPageId)
: undefined;
const activeMapFragment = activePage?.template.id === "map"
? mapDesignFragmentForLayout(applicationDesignProfileLayout, activePage.template.version)
: null;
const activeMapOverrides = activeMapLayout && activeMapFragment
? mapDesignOverridesFromResolved(activeMapLayout, activeMapFragment)
: undefined;
const draftToSave = activeMapLayout && activeApplicationPageId ? {
...applicationDraft,
pages: applicationDraft.pages.map((page) => page.id === activeApplicationPageId ? {
...page,
layout: { ...page.layout, map: activeMapLayout },
...(activeMapFragment ? {
designOverrides: activeMapOverrides
? { ...page.designOverrides, map: activeMapOverrides }
: undefined,
} : {}),
} : page),
} : applicationDraft;
setApplicationSaveState("saving");
setApplicationError("");
try {
const response = await fetch(`/api/applications/${applicationDraft.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(draftToSave),
});
if (!response.ok) throw new Error("application_save_failed");
const saved = await response.json() as ApplicationManifestV01;
setApplicationDraft(saved);
setApplicationSummaries((current) => [
{
id: saved.id,
name: saved.metadata.name,
slug: saved.metadata.slug,
status: saved.status,
version: saved.version,
theme: saved.designProfile.theme,
pageCount: saved.pages.length,
updatedAt: saved.timestamps.updatedAt,
},
...current.filter((item) => item.id !== saved.id),
]);
setApplicationSaveState("saved");
updateToast(toastId, { tone: "success", title: "Application сохранён", description: saved.metadata.name, durationMs: 4200 });
} catch {
setApplicationSaveState("error");
setApplicationError("Draft не сохранён. Проверьте название и slug.");
updateToast(toastId, { tone: "error", title: "Application не сохранён", description: "Проверьте manifest и повторите попытку.", durationMs: 6500 });
}
};
const openMapPageDesignSave = (templateVersion: string) => {
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
setDesignProfileName(currentProfile?.name ?? "");
setDesignProfileSaveScope({ kind: "page", templateId: "map", templateVersion });
setDesignProfileSaveMode("save");
setDesignProfileSaveOpen(true);
};
const changeStudioContext = (next: StudioContext) => {
setStudioContext(next);
workspace.openNavigation();
if (next === "applications" && applicationSummaries.length > 0) {
void openApplicationDraft(applicationSummaries[0].id);
} else {
workspace.closeView();
}
setApplicationError("");
};
const closeGuideline = workspace.closeNavigation;
const updateMaterial = (changes: Partial<MaterialDraft>) => {
setMaterialByTheme((current) => ({
...current,
[theme]: { ...current[theme], ...changes },
}));
};
const saveEnvironmentLayout = async () => {
setLayoutSaveState("saving");
try {
let persistedFileSrc = fileMediaSrc;
const pendingMedia = pendingMediaFileRef.current;
if (pendingMedia) {
const uploadResponse = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": "video/mp4", "x-file-name": encodeURIComponent(pendingMedia.name) },
body: pendingMedia,
});
if (!uploadResponse.ok) throw new Error("media_upload_failed");
const uploaded = await uploadResponse.json() as { url: string; fileName: string };
persistedFileSrc = uploaded.url;
setFileMediaSrc(uploaded.url);
setMediaFileName(uploaded.fileName);
pendingMediaFileRef.current = null;
}
let persistedLogoFileSrc = logoFileSrc;
const pendingLogo = pendingLogoFileRef.current;
if (pendingLogo) {
const uploadResponse = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": pendingLogo.type || "application/octet-stream", "x-file-name": encodeURIComponent(pendingLogo.name) },
body: pendingLogo,
});
if (!uploadResponse.ok) throw new Error("logo_upload_failed");
const uploaded = await uploadResponse.json() as { url: string; fileName: string };
persistedLogoFileSrc = uploaded.url;
setLogoFileSrc(uploaded.url);
setLogoFileName(uploaded.fileName);
pendingLogoFileRef.current = null;
}
let persistedFaviconAssets = faviconAssets;
if (generatedFavicons.length && pendingFaviconIcoRef.current) {
const upload = async (blob: Blob, name: string) => {
const response = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": blob.type || "application/octet-stream", "x-file-name": encodeURIComponent(name) },
body: blob,
});
if (!response.ok) throw new Error("favicon_upload_failed");
return await response.json() as { url: string };
};
const bySize = new Map(generatedFavicons.map((item) => [item.size, item]));
const [ico, apple, icon192, icon512] = await Promise.all([
upload(pendingFaviconIcoRef.current, "favicon.ico"),
upload(bySize.get(180)!.blob, "apple-touch-icon.png"),
upload(bySize.get(192)!.blob, "icon-192.png"),
upload(bySize.get(512)!.blob, "icon-512.png"),
]);
persistedFaviconAssets = { ico: ico.url, apple: apple.url, icon192: icon192.url, icon512: icon512.url };
setFaviconAssets(persistedFaviconAssets);
pendingFaviconIcoRef.current = null;
}
const draft: StoredLayout = {
theme,
accentHex,
materialByTheme,
environment: {
lightColor,
brightness,
glowDistance,
connectionType,
connectionColor,
usePortColors,
fillColor,
fillOpacity,
strokeColor,
strokeOpacity,
},
media: {
source: mediaSource,
url: mediaUrl,
fileName: mediaFileName,
fileSrc: persistedFileSrc,
visible: mediaVisible,
logoSource,
logoUrl,
logoFileName,
logoFileSrc: persistedLogoFileSrc,
faviconFileName,
faviconAssets: persistedFaviconAssets,
},
glass: glassMaterial,
toolbar: {
placement: toolbarPlacement,
background: toolbarBg,
border: toolbarBorder,
outline: toolbarOutline,
minSize: toolbarMinSize,
maxSize: toolbarMaxSize,
lensCount: toolbarLensCount,
autoHide: toolbarAutoHide,
},
};
const response = await fetch("/api/layout", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(draft),
});
if (!response.ok) throw new Error("layout_save_failed");
setLayoutSaveState("saved");
return true;
} catch {
setLayoutSaveState("error");
return false;
}
};
const saveDesignProfile = async (mode = designProfileSaveMode) => {
const selected = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!selected) return;
const toastId = pushToast(
"loading",
designProfileSaveScope.kind === "page" ? "Сохраняем дизайн страницы" : "Сохраняем Design Profile",
designProfileSaveScope.kind === "page" ? `Map@${designProfileSaveScope.templateVersion} · ${selected.name}` : selected.name,
null,
);
if (designProfileSaveScope.kind === "page") setMapTemplateSaveState("saving");
else setLayoutSaveState("saving");
try {
const currentResponse = await fetch(`/api/design-profiles/${activeDesignProfileId}`, { cache: "no-store" });
if (!currentResponse.ok) throw new Error("design_profile_load_failed");
const currentProfile = await currentResponse.json() as DesignProfileDocument;
let layout: StoredLayout;
if (designProfileSaveScope.kind === "page") {
const pageLayout = mapTemplatePreviewRef.current?.getLayout();
if (!pageLayout) throw new Error("map_page_layout_unavailable");
const pageKey = mapDesignProfileKey(designProfileSaveScope.templateVersion);
layout = {
...currentProfile.layout,
pageTypes: {
...currentProfile.layout.pageTypes,
[pageKey]: mapDesignFragmentFromLayout(pageLayout, designProfileSaveScope.templateVersion),
},
};
} else {
const layoutSaved = await saveEnvironmentLayout();
if (!layoutSaved) throw new Error("global_layout_save_failed");
const layoutResponse = await fetch("/api/layout", { cache: "no-store" });
if (!layoutResponse.ok) throw new Error("global_layout_load_failed");
const globalLayout = await layoutResponse.json() as StoredLayout;
layout = { ...globalLayout, pageTypes: currentProfile.layout.pageTypes ?? {} };
}
const creating = mode === "save-as";
const response = await fetch(creating ? "/api/design-profiles" : `/api/design-profiles/${activeDesignProfileId}`, {
method: creating ? "POST" : "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: creating ? designProfileName : selected.name, layout }),
});
if (!response.ok) throw new Error("design_profile_save_failed");
const saved = await response.json() as DesignProfileDocument;
const previous = designProfiles.find((profile) => profile.id === saved.id);
const summary: DesignProfileSummary = {
id: saved.id,
name: saved.name,
version: saved.version,
status: saved.status,
theme: saved.layout.theme === "light" ? "light" : "dark",
updatedAt: saved.timestamps.updatedAt,
versions: previous?.versions ?? [],
latestPublishedVersion: previous?.latestPublishedVersion ?? null,
};
setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name)));
setActiveDesignProfileId(summary.id);
setActiveDesignProfileLayout(saved.layout);
setDesignProfileSaveOpen(false);
setDesignProfileName("");
setLayoutSaveState("saved");
if (designProfileSaveScope.kind === "page") setMapTemplateSaveState("saved");
updateToast(toastId, {
tone: "success",
title: mode === "save-as" ? "Design Profile создан" : "Design Profile обновлён",
description: `${saved.name} · v${saved.version}`,
durationMs: 4200,
});
} catch {
if (designProfileSaveScope.kind === "page") setMapTemplateSaveState("error");
else setLayoutSaveState("error");
updateToast(toastId, { tone: "error", title: "Design Profile не сохранён", description: "Текущий профиль не изменён.", durationMs: 6500 });
}
};
const publishDesignProfile = async () => {
const current = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!current || current.versions.some((release) => release.version === current.version)) return;
const toastId = pushToast("loading", "Публикуем Design Profile", `${current.name} · v${current.version}`, null);
setDesignProfilePublishState("publishing");
const response = await fetch(`/api/design-profiles/${current.id}/publish`, { method: "POST" });
if (!response.ok) {
setDesignProfilePublishState("error");
updateToast(toastId, { tone: "error", title: "Публикация не выполнена", description: "Draft остался доступен без изменений.", durationMs: 6500 });
return;
}
const release = await response.json() as { version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { publishedAt?: string } };
setDesignProfiles((profiles) => profiles.map((profile) => profile.id !== current.id ? profile : {
...profile,
versions: [{ version: release.version, status: release.status, theme: release.layout.theme === "light" ? "light" : "dark", publishedAt: release.timestamps.publishedAt }, ...profile.versions.filter((item) => item.version !== release.version)],
latestPublishedVersion: release.version,
}));
setDesignProfilePublishState("published");
setDesignProfileSaveOpen(false);
updateToast(toastId, { tone: "success", title: "Design Profile опубликован", description: `${current.name} · v${release.version}`, durationMs: 4200 });
};
const addPageTemplateToApplication = (template: PageTemplateDefinition) => {
updateApplicationDraft((current) => {
const order = current.pages.length;
const duplicateIndex = current.pages.filter((page) => page.template.id === template.id).length + 1;
const instanceSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
const instanceId = `${template.page.id}-${instanceSuffix}`;
const instanceLabel = duplicateIndex === 1 ? template.page.navigationLabel : `${template.page.navigationLabel} ${duplicateIndex}`;
return {
...current,
pages: [...current.pages, {
id: instanceId,
title: duplicateIndex === 1 ? template.page.title : `${template.page.title} ${duplicateIndex}`,
path: order === 0 ? "/" : `/${template.page.id}-${duplicateIndex}`,
template: { id: template.id, version: template.version },
navigation: { visible: true, label: instanceLabel, order },
features: createTemplateFeatures(template),
}],
};
});
};
const removePageFromApplication = (pageId: string) => {
updateApplicationDraft((current) => ({
...current,
pages: current.pages.filter((page) => page.id !== pageId).map((page, order) => ({ ...page, navigation: { ...page.navigation, order } })),
}));
};
const confirmPageRemoval = () => {
if (!pageRemovalId) return;
removePageFromApplication(pageRemovalId);
if (activeApplicationPageId === pageRemovalId && applicationDraft) {
workspace.openView(applicationViewId(applicationDraft.id));
}
setPageRemovalId(null);
};
const reorderApplicationPages = (activeId: string, overId: string) => {
updateApplicationDraft((current) => {
const pages = [...current.pages];
const index = pages.findIndex((page) => page.id === activeId);
const target = pages.findIndex((page) => page.id === overId);
if (index < 0 || target < 0 || index === target) return current;
const [moved] = pages.splice(index, 1);
pages.splice(target, 0, moved);
return { ...current, pages: pages.map((page, order) => ({ ...page, navigation: { ...page.navigation, order } })) };
});
};
const setApplicationPageOrder = (ids: string[]) => {
updateApplicationDraft((current) => {
const pagesById = new Map(current.pages.map((page) => [page.id, page]));
const pages = ids.map((id) => pagesById.get(id)).filter((page): page is ApplicationManifestV01["pages"][number] => Boolean(page));
if (pages.length !== current.pages.length) return current;
return { ...current, pages: pages.map((page, order) => ({ ...page, navigation: { ...page.navigation, order } })) };
});
};
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>
<RangeControl label="Прозрачность плашки" value={material.panelOpacity} min={18} max={96} formatValue={(value) => `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} />
<ControlRow label="Поля ввода">
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
</ControlRow>
<RangeControl label="Прозрачность полей" value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
<ControlRow label="Вложенная область">
<ColorField label="Цвет вложенной области" value={material.nestedHex} onChange={(nestedHex) => updateMaterial({ nestedHex })} />
</ControlRow>
</>
),
},
{
id: "toolbar-settings",
label: "Рабочее поле — Toolbar",
tone: "accent" as const,
content: (
<>
<InspectorSelectField
label="Позиция Toolbar"
value={toolbarPlacement}
options={[
{ value: "left", label: "Слева" },
{ value: "right", label: "Справа" },
{ value: "bottom", label: "Снизу" },
]}
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
/>
<ControlRow label="Фон"><ColorField value={toolbarBg} onChange={setToolbarBg} /></ControlRow>
<ControlRow label="Border"><ColorField value={toolbarBorder} onChange={setToolbarBorder} /></ControlRow>
<ControlRow label="Outline"><ColorField value={toolbarOutline} onChange={setToolbarOutline} /></ControlRow>
<RangeControl label="Минимальный размер" value={toolbarMinSize} min={18} max={42} formatValue={(value) => `${value}px`} onChange={(value) => {
const next = Math.round(value);
setToolbarMinSize(next);
setToolbarMaxSize((current) => Math.max(current, next));
}} />
<RangeControl label="Максимальный размер" value={toolbarMaxSize} min={28} max={88} formatValue={(value) => `${value}px`} onChange={(value) => setToolbarMaxSize(Math.max(toolbarMinSize, Math.round(value)))} />
<RangeControl label="Линза" value={toolbarLensCount} min={1} max={13} step={2} formatValue={(value) => `${value} иконок`} onChange={(value) => setToolbarLensCount(Math.round(value) % 2 === 0 ? Math.round(value) + 1 : Math.round(value))} />
<Checker checked={toolbarAutoHide} label="Автоскрытие" onChange={setToolbarAutoHide} />
</>
),
},
{
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} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
</>
),
},
{
id: "connections",
label: "Связи",
tone: "accent" as const,
content: (
<>
<InspectorSelectField
label="Связи по умолчанию"
value={connectionType}
options={[
{ value: "spline", label: "Сплайн" },
{ value: "straight", label: "Прямая" },
{ value: "step", label: "Ступенчатая" },
]}
onChange={setConnectionType}
/>
<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 "controls":
return (
<div className="catalog-grid">
<Preview title="Selection" note="integrated / split / inline">
<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>
<FieldFrame label="Toolbar / inline select">
<Select
variant="inline"
label="Скорость воспроизведения"
value="1"
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
onChange={() => undefined}
/>
</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="Поля" 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="Действия" note="общая геометрия" className="catalog-preview--compact">
<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="default / compact" className="catalog-preview--compact">
<div className="catalog-inline catalog-inline--wrap">
<span className="catalog-inline">
<ActivityIndicator label="Загружаем данные" />
<span>Загружаем данные</span>
</span>
<Button
aria-busy="true"
disabled
icon={<ActivityIndicator size="compact" />}
>Подключаем…</Button>
</div>
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
</Preview>
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
<ProgressBar label="Получение сведений" />
<ProgressBar label="Завершено" value={1} />
</Preview>
<Preview title="Строки ресурсов" note="Mission Core / общий список">
<ResourceList aria-label="Пример списка ресурсов">
<li><ResourceRow icon={<Icon name="file" />} title="Сохранённый результат" metadata="Сегодня · доступен для просмотра" actions={<IconButton label="Просмотреть пример" onClick={() => setProjectName("Сохранённый результат")}><Icon name="eye" /></IconButton>} /></li>
<li><ResourceRow icon={<Icon name="camera" />} title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
</ResourceList>
</Preview>
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
<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>
<Preview title="Вертикальная рейка" note="glass pill / круг `46 px`" className="catalog-preview--compact">
<GlassSurface
className="catalog-icon-rail-demo"
tone="strong"
radius="pill"
padding="sm"
materialRim={false}
role="toolbar"
aria-label="Режимы просмотра"
>
<IconButton label="Камера" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="3D"><span aria-hidden="true">3D</span></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
</GlassSurface>
</Preview>
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
{workspaceWindowDemoOpen ? (
<WorkspaceWindow
boundsRef={workspaceWindowDemoRef}
rect={workspaceWindowDemoRect}
onRectChange={setWorkspaceWindowDemoRect}
maximized={workspaceWindowDemoMaximized}
onMaximizedChange={setWorkspaceWindowDemoMaximized}
onActivate={() => undefined}
onClose={() => {
setWorkspaceWindowDemoOpen(false);
setWorkspaceWindowDemoMaximized(false);
}}
title="Визуальный канал"
subtitle="WORKSPACE / AUXILIARY VIEW"
status="LIVE"
footer={<span className="catalog-workspace-window-demo__hint">Стрелки позиция · Shift + стрелки 10 px</span>}
active
zIndex={2}
>
<div className="catalog-workspace-window-demo__media">
<Icon name="video" size={24} />
<strong>Рабочая поверхность</strong>
<span>Перемещается, изменяет размер и разворачивается только внутри сцены.</span>
</div>
</WorkspaceWindow>
) : (
<Button
className="catalog-workspace-window-demo__reopen"
shape="pill"
icon={<Icon name="plus" />}
onClick={() => setWorkspaceWindowDemoOpen(true)}
>Открыть окно</Button>
)}
</div>
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
</Preview>
<Preview title="Split pane" note="pointer / keyboard / controlled" className="catalog-preview--wide catalog-split-pane-preview">
<div className="catalog-split-pane-demo">
<SplitPane
primarySize={splitPaneDemoSize}
onPrimarySizeChange={setSplitPaneDemoSize}
minPrimarySize={25}
minSecondarySize={25}
separatorLabel="Изменить ширину синхронных представлений"
primary={<div className="catalog-split-pane-demo__panel"><Icon name="video" /><strong>Видео</strong></div>}
secondary={<div className="catalog-split-pane-demo__panel"><Icon name="grid" /><strong>Пространственная сцена</strong></div>}
/>
</div>
<p className="catalog-preview__explanation">Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.</p>
</Preview>
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
<div className="catalog-side-panel-demo">
{sidePanelDemoOpen ? (
<ApplicationSidePanel
eyebrow="MAP / SETTINGS"
title="Настройки карты"
description="Theme-aware application panel"
onClose={() => setSidePanelDemoOpen(false)}
>
<Inspector variant="panel" sections={environmentSections.slice(0, 3)} defaultOpen={[environmentSections[0]?.id ?? ""]} singleOpen />
</ApplicationSidePanel>
) : (
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setSidePanelDemoOpen(true)}>Открыть правую панель</Button>
)}
</div>
<p className="catalog-preview__explanation">Панель занимает отдельную end-колонку ApplicationShell, сдвигает рабочую область, наследует тему и не входит в z-index stack окон карты.</p>
</Preview>
<Preview title="Inspector" note="modeless / draggable" className="catalog-preview--wide catalog-preview--compact catalog-inspector-launcher">
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
</Preview>
</div>
);
case "media":
return (
<div className="catalog-grid catalog-grid--single">
<SettingsCard
eyebrow="CONTENT"
title="Видео-окно"
description="MP4 или URL становятся частью единого server-side layout вместе с темой, материалом и Toolbar."
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 сразу показывается в stage и загружается на сервер общей кнопкой Save в шапке."
error={mediaError}
onSourceChange={setMediaSource}
onUrlChange={(nextUrl) => {
setMediaUrl(nextUrl);
setMediaError(nextUrl && !/\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на видеофайл." : "");
}}
onFileChange={handleStageMediaFile}
/>
</SettingsCard>
<SettingsCard
eyebrow="BRAND"
title="Знак приложения"
description="CMS media-контракт для центрального workspace mark в верхней шапке."
>
<MediaSourceField
label="Логотип / изображение"
source={logoSource}
url={logoUrl}
fileName={logoFileName}
previewSrc={headerMarkSrc}
previewKind="image"
accept="image/png,image/jpeg,image/gif,image/webp,.png,.jpg,.jpeg,.gif,.webp"
path="header.workspaceMark → runtime media source"
hint="Изображение сразу меняет центральный знак в шапке и загружается на сервер общей кнопкой Save."
error={logoError}
onSourceChange={setLogoSource}
onUrlChange={(nextUrl) => {
setLogoUrl(nextUrl);
setLogoError(nextUrl && !/\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на изображение." : "");
}}
onFileChange={handleLogoFile}
/>
</SettingsCard>
<SettingsCard
eyebrow="BROWSER BRAND"
title="Favicon generator"
description="Один квадратный исходник превращается в ICO, Apple Touch и PWA-набор; общая Save сохраняет набор на сервере и применяет его к приложению."
>
<div className="catalog-favicon-generator">
<label className="nodedc-media-file__button catalog-favicon-generator__upload">
Выбрать и сгенерировать
<input type="file" accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" onChange={(event) => { void handleFaviconFile(event.target.files?.[0]); }} />
</label>
<span className="catalog-favicon-generator__name">{faviconFileName}</span>
<div className="catalog-favicon-grid">
{[16, 32, 64, 180, 192, 512].map((size) => {
const generated = generatedFavicons.find((item) => item.size === size);
const fallback = size === 180 ? faviconAssets.apple : size === 192 ? faviconAssets.icon192 : size === 512 ? faviconAssets.icon512 : faviconAssets.ico;
return <figure key={size}><span><img src={generated?.previewUrl ?? fallback} alt="" /></span><figcaption>{size} × {size}</figcaption></figure>;
})}
</div>
{faviconError ? <span className="nodedc-media-field__error" role="alert">{faviconError}</span> : null}
</div>
</SettingsCard>
</div>
);
case "glass":
return (
<div className="catalog-glass-lab">
<section className="catalog-glass-stage">
<video key={stageMediaSrc} autoPlay muted loop playsInline aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<div className="catalog-glass-stage__shade" />
<GlassMaterialSurface className="catalog-glass-stage__sample">
<span>CANONICAL GLASS</span>
<h2>Модальное окно и Inspector</h2>
<p>Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.</p>
<div className="catalog-inline"><TextField label="Пример поля" defaultValue="NODE.DC" /><Button variant="primary" shape="pill">Применить</Button></div>
</GlassMaterialSurface>
</section>
<SettingsCard eyebrow="ENGINE / GLASS V4" title="Параметры материала" description="Общий token-контракт ui-core; изменения сразу видны на preview, модалках и Inspector.">
<div className="catalog-glass-controls">
<ControlRow label="Цвет тонировки"><ColorField value={glassMaterial.tintHex} onChange={(tintHex) => setGlassMaterial((current) => ({ ...current, tintHex }))} /></ControlRow>
<RangeControl label="Плотность тонировки" value={glassMaterial.tintOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(tintOpacity) => setGlassMaterial((current) => ({ ...current, version: 4, tintOpacity }))} />
<RangeControl label="Blur" value={glassMaterial.blur} min={0} max={120} formatValue={(value) => `${value}px`} onChange={(blur) => setGlassMaterial((current) => ({ ...current, blur }))} />
<RangeControl label="Saturation" value={glassMaterial.saturation} min={70} max={220} formatValue={(value) => `${value}%`} onChange={(saturation) => setGlassMaterial((current) => ({ ...current, saturation }))} />
<RangeControl label="Brightness" value={glassMaterial.brightness} min={70} max={160} formatValue={(value) => `${value}%`} onChange={(brightness) => setGlassMaterial((current) => ({ ...current, brightness }))} />
<RangeControl label="Контур" value={glassMaterial.outlineOpacity} min={0} max={40} formatValue={(value) => `${value}%`} onChange={(outlineOpacity) => setGlassMaterial((current) => ({ ...current, outlineOpacity }))} />
<RangeControl label="Тень" value={glassMaterial.shadowOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(shadowOpacity) => setGlassMaterial((current) => ({ ...current, shadowOpacity }))} />
</div>
</SettingsCard>
</div>
);
case "status":
return (
<div className="catalog-status-library">
<SettingsCard
eyebrow="TASKER / PROPEL CANON"
title="Статусные уведомления"
description="Один стеклянный паттерн для сохранения, обновления, предупреждения и ошибки. Runtime-стек появляется снизу справа и не блокирует интерфейс."
>
<div className="catalog-toast-library-grid">
<ToastCard item={{ id: "preview-success", tone: "success", title: "Проект сохранён", description: "Application manifest обновлён." }} />
<ToastCard item={{ id: "preview-info", tone: "info", title: "Профиль применён", description: "Map-фрагмент будет использован страницами этого типа." }} />
<ToastCard item={{ id: "preview-warning", tone: "warning", title: "Есть несохранённые изменения", description: "Текущий draft отличается от release." }} />
<ToastCard item={{ id: "preview-error", tone: "error", title: "Сохранение не выполнено", description: "Предыдущее состояние не изменено." }} />
<ToastCard item={{ id: "preview-loading", tone: "loading", title: "Сохраняем Design Profile", description: "Пожалуйста, не закрывайте окно." }} />
</div>
</SettingsCard>
<SettingsCard eyebrow="LIVE STATES" title="Проверка стека" description="Кнопки используют тот же bottom-right viewport, что и реальные save/update flows.">
<div className="catalog-inline catalog-inline--wrap">
<Button onClick={() => pushToast("success", "Изменения сохранены", "Статус автоматически исчезнет.")}>Success</Button>
<Button onClick={() => pushToast("info", "Профиль применён", "Application использует выбранную версию.")}>Info</Button>
<Button onClick={() => pushToast("warning", "Нужна проверка", "Перед публикацией проверьте preview.")}>Warning</Button>
<Button variant="danger" onClick={() => pushToast("error", "Операция не выполнена", "Состояние не изменено.", 6500)}>Error</Button>
<Button icon={<Icon name="refresh" />} onClick={() => pushToast("loading", "Выполняется операция", "Закройте вручную после проверки.", null)}>Loading</Button>
</div>
</SettingsCard>
</div>
);
case "modals":
return (
<div className="catalog-modal-groups">
<section className="catalog-modal-group">
<header><strong>Общие</strong><span>Launcher / Engine</span></header>
<div className="catalog-modal-grid">
<Preview title="Создание" note="адаптивная форма" className="catalog-modal-preview">
<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" className="catalog-modal-preview">
<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" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец.</p>
<Button variant="primary" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
</Preview>
<Preview title="Сохранить / Сохранить как" note="DESIGN PROFILE" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Обновляет текущий preset или создаёт новый именованный профиль.</p>
<Button shape="pill" icon={<Icon name="save" />} onClick={() => { setDesignProfileSaveScope({ kind: "global" }); setDesignProfileSaveMode("save"); setDesignProfileSaveOpen(true); }}>Открыть сохранение</Button>
</Preview>
</div>
</section>
<section className="catalog-modal-group">
<header><strong>Работа с моделями</strong><span>BIM Viewer</span></header>
<div className="catalog-modal-grid">
<Preview title="Ссылка на модель" note="share link" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Read-only ссылка и copy-state.</p>
<Button shape="pill" icon={<Icon name="copy" />} onClick={() => setShareLinkOpen(true)}>Открыть ссылку</Button>
</Preview>
<Preview title="Контекстные действия" note="measurement / object" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Действия над выбранным размером или объектом.</p>
<Button shape="pill" icon={<Icon name="sliders" />} onClick={() => setContextActionOpen(true)}>Действия размера</Button>
</Preview>
<Preview title="История версий" note="large data" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Прокручиваемая история BIM-версий.</p>
<Button shape="pill" icon={<Icon name="list" />} onClick={() => setHistoryOpen(true)}>Открыть историю</Button>
</Preview>
<Preview title="Комментарий" note="expandable detail" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Контент, вложения и detail-состояние.</p>
<Button shape="pill" icon={<Icon name="clipboard" />} onClick={() => setDetailOpen(true)}>Открыть комментарий</Button>
</Preview>
</div>
</section>
</div>
);
case "icons":
return (
<div className="catalog-icon-catalog">
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
<div className="catalog-window-actions-demo">
<IconButton label="Камера включена" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
<p className="catalog-preview__explanation">Все glyph-иконки используют один размер 16 px и один stroke 1.6; surface меняет только фон и 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} /></span>
<span><strong>{iconLabels[name]}</strong><code>{name}</code></span>
</div>
))}
</div>
</section>
))}
</div>
);
default:
return null;
}
};
const renderApplicationDraft = () => {
if (!applicationDraft || applicationDraft.id !== activeApplicationId) return null;
const selectedProfile = designProfiles.find((profile) => profile.id === applicationDraft.designProfile.id) ?? designProfiles[0];
const designProfileOptions = designProfiles.flatMap((profile) => [
...profile.versions.map((release) => ({
value: `${profile.id}@${release.version}@published`,
label: profile.name,
description: `${release.version} · Published · ${release.theme}`,
})),
{
value: `${profile.id}@${profile.version}@draft`,
label: `${profile.name} — Draft`,
description: `${profile.version} · Draft · ${profile.theme}`,
},
]);
const selectedProfileValue = `${applicationDraft.designProfile.id}@${applicationDraft.designProfile.version}@${applicationDraft.designProfile.status}`;
const availableTemplates = pageTemplates;
if (applicationMode === "preview") {
return (
<div className="catalog-module-preview">
<div className="catalog-module-preview__navigation">
{applicationDraft.pages.length ? applicationDraft.pages.map((page) => <span key={page.id}>{page.navigation.label}</span>) : <span>В приложении пока нет страниц</span>}
</div>
<div className="catalog-module-preview__stage">
<Icon name="apps" />
<strong>{applicationDraft.metadata.name}</strong>
<span>{applicationDraft.pages.length ? "Выберите страницу в левой панели для полного preview." : "Вернитесь в режим настройки и добавьте готовый Page Template."}</span>
</div>
</div>
);
}
return (
<div className="catalog-application-draft">
<SettingsCard
eyebrow="APPLICATION MANIFEST v0.1"
title="Основные настройки модуля"
description="Root проекта: metadata, Design Profile и состав готовых страниц."
>
<div className="catalog-form catalog-application-draft__fields">
<TextField
label="Название приложения"
hint="обязательно"
value={applicationDraft.metadata.name}
onChange={(event) => updateApplicationDraft((current) => ({
...current,
metadata: { ...current.metadata, name: event.target.value },
}))}
/>
<TextField
label="Slug"
hint="латиница, цифры и дефис"
value={applicationDraft.metadata.slug}
onChange={(event) => updateApplicationDraft((current) => ({
...current,
metadata: { ...current.metadata, slug: event.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, "-") },
}))}
/>
<TextAreaField
label="Описание"
value={applicationDraft.metadata.description}
onChange={(event) => updateApplicationDraft((current) => ({
...current,
metadata: { ...current.metadata, description: event.target.value },
}))}
/>
</div>
</SettingsCard>
<SettingsCard
eyebrow="DESIGN PROFILE"
title="Визуальный пресет"
description="Модуль только ссылается на сохранённый профиль Visual Library. Тема, favicon и материалы наследуются из него."
>
<Select
label="Design Profile"
value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`}
options={designProfileOptions}
onChange={(profileReference) => { void selectApplicationDesignProfile(profileReference); }}
/>
</SettingsCard>
<SettingsCard
eyebrow="APPLICATION COMPOSITION"
title="Состав страниц"
description="Перетащите готовую страницу справа в конфигурацию приложения. Свободного canvas и произвольных компонентов нет."
>
<DragDropRoot onDragEnd={({ activeData, activeId, activeRect, overData, overId, overRect }) => {
if (!overId) return;
if (activeData?.type === "page-template" && (overId === "application-pages" || overData?.type === "application-page")) {
const horizontalOverlap = activeRect && overRect
? Math.max(0, Math.min(activeRect.right, overRect.right) - Math.max(activeRect.left, overRect.left))
: 0;
const overlapRatio = activeRect?.width ? horizontalOverlap / activeRect.width : 0;
if (overlapRatio < 0.2) return;
const template = getPageTemplate(String(activeData.templateId));
if (template) addPageTemplateToApplication(template);
return;
}
if (activeData?.type === "application-page" && overData?.type === "application-page") {
reorderApplicationPages(String(activeData.pageId ?? activeId), String(overData.pageId ?? overId));
}
}}>
<div className="catalog-page-composer">
<section className="catalog-page-composer__column">
<header><span>01</span><div><strong>Доступные страницы</strong><small>Page Library · шаблон можно использовать многократно</small></div></header>
<div className="catalog-page-composer__list">
{availableTemplates.map((template) => (
<DraggableItem key={template.id} id={`template:${template.id}`} data={{ type: "page-template", templateId: template.id }} className="catalog-page-composer__draggable">
{({ handle }) => (
<div className="catalog-page-composer__row">
<span className="catalog-page-composer__icon"><Icon name="globe" /></span>
<span><strong>{template.title}</strong><small>{template.version} · {template.category}</small></span>
{handle}
</div>
)}
</DraggableItem>
))}
</div>
</section>
<DropZone id="application-pages" data={{ type: "application-pages" }} className="catalog-page-composer__column">
<header><span>02</span><div><strong>Конфигурация приложения</strong><small>{applicationDraft.pages.length} страниц</small></div></header>
<SortableScope ids={applicationDraft.pages.map((page) => `page:${page.id}`)}>
<div className="catalog-page-composer__list">
{applicationDraft.pages.map((page) => (
<SortableItem key={page.id} id={`page:${page.id}`} data={{ type: "application-page", pageId: page.id }} className="catalog-page-composer__draggable">
{({ handle }) => (
<div className="catalog-page-composer__row catalog-page-composer__row--configured">
<span className="catalog-page-composer__icon"><Icon name="globe" /></span>
<span><strong>{page.title}</strong><small>{page.template.id} · {page.template.version}</small></span>
{handle}
<IconButton label="Убрать страницу" onClick={() => setPageRemovalId(page.id)}><Icon name="close" /></IconButton>
</div>
)}
</SortableItem>
))}
{!applicationDraft.pages.length ? <div className="catalog-page-composer__empty">Перетащите сюда готовый Page Template.</div> : null}
</div>
</SortableScope>
</DropZone>
</div>
</DragDropRoot>
<div className="catalog-page-composer__footer">
<span data-state={applicationSaveState}>{applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "saved" ? "Конфигурация сохранена" : applicationSaveState === "error" ? "Ошибка сохранения" : "Есть несохранённые изменения"}</span>
</div>
</SettingsCard>
{applicationError ? <p className="nodedc-media-field__error" role="alert">{applicationError}</p> : null}
</div>
);
};
const renderPageTemplate = (template: PageTemplateDefinition) => {
const mapProfileFragment = template.id === "map"
? mapDesignFragmentForLayout(activeDesignProfileLayout, template.version)
: null;
const mapDesignLayout = template.id === "map"
? resolveMapDesignLayout(mapTemplateLayout ?? createDefaultMapPageLayout(true), mapProfileFragment)
: null;
return template.id === "map" ? (
<div className="catalog-page-template catalog-page-template--map">
<MapFixturePreview
key={`map-template-${activeDesignProfileId}-${mapDesignProfileKey(template.version)}-${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? "draft"}`}
ref={mapTemplatePreviewRef}
expanded
initialLayout={mapDesignLayout}
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
settingsPanelHost={mapSettingsPanelHost}
headerActionsHost={mapHeaderActionsHost}
onSettingsPanelOpenChange={setMapSettingsPanelOpen}
/>
<div className="catalog-page-template__actions">
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => { void createApplicationDraft(template); }}>Создать Application Draft</Button>
<span>Карта масштабируется по высоте за правый нижний угол. Настройки через Inspector.</span>
</div>
</div>
) : (
<div className="catalog-page-template">
<SettingsCard
eyebrow={`PAGE TEMPLATE ${template.version}`}
title={template.title}
description={template.description}
>
<MapFixturePreview
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
settingsPanelHost={mapSettingsPanelHost}
headerActionsHost={mapHeaderActionsHost}
onSettingsPanelOpenChange={setMapSettingsPanelOpen}
/>
<div className="catalog-page-template__actions">
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => { void createApplicationDraft(template); }}>Создать Application Draft</Button>
<span>Только утверждённые features и slots без свободного canvas.</span>
</div>
</SettingsCard>
<div className="catalog-grid">
<SettingsCard eyebrow="FEATURE FLAGS" title="Разрешённые функции" description="Template владеет составом; пользователь только включает или скрывает разрешённое.">
<div className="catalog-page-template__list">
{template.features.map((feature) => (
<div key={feature.id}><Icon name="check" /><span><strong>{feature.label}</strong><small>{feature.description}</small></span><code>{feature.required ? "required" : feature.defaultVisible ? "visible" : "hidden"}</code></div>
))}
</div>
</SettingsCard>
<SettingsCard eyebrow="SLOT CONTRACT" title="Данные и команды" description="Runtime-neutral slots; Cesium и Engine не зашиты в визуальный шаблон.">
<div className="catalog-page-template__list">
{template.slots.map((slot) => (
<div key={slot.id}><Icon name={slot.kind === "command" ? "activity" : "database"} /><span><strong>{slot.label}</strong><small>{slot.description}</small></span><code>{slot.kind}</code></div>
))}
</div>
</SettingsCard>
</div>
</div>
);
};
const renderApplicationPage = () => {
if (!applicationDraft || !activeApplicationPageId) return null;
const page = applicationDraft.pages.find((item) => item.id === activeApplicationPageId);
if (!page) return null;
const template = getPageTemplate(page.template.id, page.template.version);
if (!template) return null;
const mapProfileFragment = page.template.id === "map"
? mapDesignFragmentForLayout(applicationDesignProfileLayout, page.template.version)
: null;
const resolvedMapLayout = page.template.id === "map"
? resolveMapDesignLayout(page.layout?.map ?? createDefaultMapPageLayout(true), mapProfileFragment, page.designOverrides?.map)
: null;
const setFeature = (featureId: string, value: boolean) => updateApplicationDraft((current) => ({
...current,
pages: current.pages.map((item) => item.id === page.id ? { ...item, features: { ...item.features, [featureId]: value } } : item),
}));
return (
<div className="catalog-application-page">
<MapFixturePreview
key={`${page.id}-${applicationDraft.designProfile.id}-${applicationDraft.designProfile.version}-${applicationDraft.designProfile.status}`}
ref={applicationMapPreviewRef}
applicationId={applicationDraft.id}
pageId={page.id}
initialLayout={resolvedMapLayout}
features={page.features}
expanded
settingsPanelHost={mapSettingsPanelHost}
headerActionsHost={mapHeaderActionsHost}
onSettingsPanelOpenChange={setMapSettingsPanelOpen}
/>
{applicationMode === "edit" ? (
<SettingsCard eyebrow="PAGE SETTINGS" title={page.title} description={`${template.title} · ${template.version}`}>
<div className="catalog-application-draft__features">
<Switch checked={page.navigation.visible} label="Показывать в навигации" onChange={(visible) => updateApplicationDraft((current) => ({ ...current, pages: current.pages.map((item) => item.id === page.id ? { ...item, navigation: { ...item.navigation, visible } } : item) }))} />
{template.features.map((feature) => (
<Switch key={feature.id} checked={Boolean(page.features[feature.id])} label={("required" in feature && feature.required) ? `${feature.label} · обязательно` : feature.label} onChange={(value) => setFeature(feature.id, ("required" in feature && feature.required) ? true : value)} />
))}
</div>
</SettingsCard>
) : null}
</div>
);
};
const activeDefinition = activeSection ? sectionDefinitions[activeSection] : null;
const activeApplication = applicationDraft && applicationDraft.id === activeApplicationId ? applicationDraft : null;
const activePageTemplate = activePageTemplateId ? getPageTemplate(activePageTemplateId) : undefined;
const activeApplicationPage = activeApplicationPageId ? activeApplication?.pages.find((page) => page.id === activeApplicationPageId) : undefined;
return (
<>
<ApplicationShell
className="catalog-app nodedc-ui-root"
style={{ "--catalog-accent": accentHex } as CSSProperties}
navigationOpen={guidelineOpen}
contentOpen={workspace.contentOpen}
contentExpanded={panelExpanded}
endPanelOpen={mapSettingsPanelOpen}
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
header={
<AppHeader
brandMonochrome
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
center={
<>
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl={headerMarkSrc} />
<HeaderNavigation
label="Рабочая область Module Foundry"
value={guidelineOpen ? studioContext : undefined}
items={[
{ value: "visual", label: "Visual Library" },
{ value: "pages", label: "Page Library" },
{ value: "applications", label: "Applications" },
]}
onChange={changeStudioContext}
/>
<SegmentedControl
className="catalog-header-theme-switch"
label="Тема приложения"
value={theme}
items={[{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]}
onChange={setTheme}
/>
</>
}
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
<UserProfileMenu
displayName={sessionProfile?.user?.displayName || "Профиль"}
subtitle={sessionProfile?.user?.email || "NODE.DC Foundry"}
avatarUrl={sessionProfile?.user?.avatarUrl || undefined}
actions={[
{ id: "profile", label: "Профиль", icon: "profile", href: sessionProfile?.profileUrl || undefined, disabled: !sessionProfile?.profileUrl },
{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setFoundrySettingsOpen(true) },
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
]}
/>
</HeaderProfile>
}
/>
}
stage={
<section className="catalog-launcher-stage">
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline 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>{studioContext === "visual" ? <>DESIGN<br />GUIDELINE</> : <>MODULE<br />STUDIO</>}</strong>
<p>{studioContext === "applications" ? "Application Drafts на основе версионируемых шаблонов NODE.DC." : studioContext === "pages" ? "Готовые смысловые страницы с фиксированным контрактом функций и данных." : "Общий форм-фактор приложений, компоненты и оконная механика платформы."}</p>
</div>
</section>
}
navigation={
<AdminNavigationPanel
eyebrow="NODE.DC"
title={studioContext === "applications" ? "Applications" : studioContext === "pages" ? "Page Library" : "Visual Library"}
closeLabel="Закрыть Module Foundry"
navigationLabel={studioContext === "applications" ? "Application Drafts" : studioContext === "pages" ? "Page Templates" : "Разделы Visual Library"}
onClose={closeGuideline}
headerActions={studioContext === "applications" ? (
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
) : undefined}
contextSlot={studioContext === "applications" ? (
<Select
className="catalog-module-switcher"
label="Текущий модуль"
value={activeApplicationId ?? applicationSummaries[0]?.id ?? ""}
options={applicationSummaries.map((application) => ({ value: application.id, label: application.name, description: `${application.version} · ${application.status}`, icon: <Icon name="apps" /> }))}
searchable
searchPlaceholder="Поиск модулей"
emptyLabel="Модули не созданы"
onChange={(id) => { void openApplicationDraft(id); }}
/>
) : studioContext === "visual" || studioContext === "pages" ? (
<Select
className="catalog-module-switcher"
label="Design Profile"
value={activeDesignProfileId}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · Draft · ${profile.versions.length} releases`, icon: <Icon name="settings" /> }))}
onChange={(id) => { void openDesignProfile(id); }}
/>
) : undefined}
items={studioContext === "applications"
? activeApplication ? [
{ id: "__root__", label: "Общие настройки", icon: <Icon name="settings" /> },
...activeApplication.pages.filter((page) => page.navigation.visible).map((page) => ({ id: page.id, label: page.navigation.label, icon: <Icon name="globe" />, sortable: true })),
] : []
: studioContext === "pages"
? pageTemplates.map((template) => ({
id: template.id,
label: template.title,
icon: <Icon name="globe" />,
}))
: (Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
id,
label: item.title,
icon: <Icon name={item.icon} />,
}))}
activeId={studioContext === "applications" ? activeApplicationPageId ?? (activeApplication ? "__root__" : undefined) : studioContext === "pages" ? activePageTemplateId ?? undefined : activeSection ?? undefined}
onItemChange={(id) => studioContext === "applications"
? activeApplication && workspace.openView(id === "__root__" ? applicationViewId(activeApplication.id) : applicationPageViewId(activeApplication.id, id))
: studioContext === "pages" ? openPageTemplate(id) : openSection(id)}
onItemsReorder={studioContext === "applications" ? setApplicationPageOrder : undefined}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name={studioContext === "applications" ? "apps" : studioContext === "pages" ? "globe" : "shield"} /></span>
<span>{studioContext === "applications" ? activeApplication ? `${activeApplication.metadata.name} · ${activeApplication.version}` : `${applicationSummaries.length} drafts` : studioContext === "pages" ? `${pageTemplates.length} templates` : "Design Guideline 0.7.0"}</span>
</>
}
/>
}
content={activeDefinition ? (
<ApplicationPanel
key={activeSection}
eyebrow={activeDefinition.eyebrow}
title={activeDefinition.title}
description={activeDefinition.description}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
utilityActions={[{
label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
icon: "save",
onClick: () => {
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
setDesignProfileName(currentProfile?.name ?? "");
setDesignProfileSaveScope({ kind: "global" });
setDesignProfileSaveMode("save");
setDesignProfileSaveOpen(true);
},
disabled: layoutSaveState === "saving" || layoutSaveState === "loading",
}]}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderSectionContent()}</div>
</ApplicationPanel>
) : activePageTemplate ? (
<ApplicationPanel
key={`${activePageTemplate.id}@${activePageTemplate.version}`}
eyebrow="PAGE LIBRARY / CANONICAL"
title={activePageTemplate.title}
description={`${activePageTemplate.category} · contract ${activePageTemplate.schemaVersion} · template ${activePageTemplate.version}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
headerTools={activePageTemplate.id === "map" ? <div ref={setMapHeaderActionsHost} className="catalog-map-header-actions-host" /> : undefined}
utilityActions={activePageTemplate.id === "map" ? [{
label: mapTemplateSaveState === "saving" ? "Сохраняем Map-фрагмент в Design Profile" : mapTemplateSaveState === "error" ? "Повторить сохранение Map-фрагмента" : "Сохранить дизайн Map в Design Profile",
icon: "save",
onClick: () => openMapPageDesignSave(activePageTemplate.version),
disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading",
}] : undefined}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderPageTemplate(activePageTemplate)}</div>
</ApplicationPanel>
) : activeApplication ? (
<ApplicationPanel
key={activeApplication.id}
eyebrow={activeApplicationPage ? "APPLICATION / PAGE" : "APPLICATION / ROOT"}
title={activeApplicationPage?.title ?? activeApplication.metadata.name}
description={activeApplicationPage ? `${activeApplication.metadata.name} · ${activeApplicationPage.template.id}@${activeApplicationPage.template.version}` : `/${activeApplication.metadata.slug} · manifest ${activeApplication.schemaVersion}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
headerTools={(
<div className="catalog-application-header-tools">
<SegmentedControl label="Режим модуля" value={applicationMode} items={[{ value: "edit", label: "Настройка" }, { value: "preview", label: "Предпросмотр" }]} onChange={setApplicationMode} />
{activeApplicationPage?.template.id === "map" ? <div ref={setMapHeaderActionsHost} className="catalog-map-header-actions-host" /> : null}
</div>
)}
utilityActions={[{
label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
icon: "save",
onClick: () => { void saveApplicationDraft(); },
disabled: applicationSaveState === "saving" || applicationSaveState === "loading",
}, {
label: "Удалить модуль",
icon: "trash",
onClick: () => setDeleteModuleOpen(true),
}]}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">
{activeApplicationPage ? renderApplicationPage() : renderApplicationDraft()}
</div>
</ApplicationPanel>
) : null}
/>
{guidelineOpen && studioContext === "visual" ? (
<Toolbar<CatalogSection>
placement={toolbarPlacement}
background={toolbarBg}
border={toolbarBorder}
outline={toolbarOutline}
accent={accentHex}
minSize={toolbarMinSize}
maxSize={toolbarMaxSize}
lensCount={toolbarLensCount}
autoHide={toolbarAutoHide}
items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
id,
label: item.title,
icon: item.icon,
active: activeSection === id,
onSelect: openSection,
}))}
/>
) : null}
<Window
open={inspectorOpen}
title="Настройки окружения"
subtitle="ENGINE / draggable inspector"
placement="end"
draggable
onClose={() => setInspectorOpen(false)}
>
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
</Window>
<Window
open={createModuleOpen}
title="Создать модуль"
subtitle="APPLICATION / DRAFT"
size="sm"
onClose={() => setCreateModuleOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateModuleOpen(false)}>Отмена</Button>
<WindowFooterActions>
<Button
variant="primary"
shape="pill"
icon={<Icon name="plus" />}
disabled={!createModuleName.trim() || !createModuleSlug.trim() || applicationSaveState === "saving"}
onClick={() => { void createApplicationDraft(undefined, { name: createModuleName, slug: createModuleSlug, description: createModuleDescription }); }}
>Создать модуль</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<TextField label="Название модуля" hint="обязательно" value={createModuleName} onChange={(event) => {
const value = event.target.value;
setCreateModuleName(value);
setCreateModuleSlug((current) => current ? current : value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
}} />
<TextField label="Slug" hint="латиница, цифры и дефис" value={createModuleSlug} onChange={(event) => setCreateModuleSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, "-"))} />
<TextAreaField label="Описание" value={createModuleDescription} onChange={(event) => setCreateModuleDescription(event.target.value)} />
</div>
</Window>
<Window
open={designProfileSaveOpen}
title="Сохранить Design Profile"
subtitle={designProfileSaveScope.kind === "page" ? `PAGE LIBRARY / ${designProfileSaveScope.templateId.toUpperCase()}@${designProfileSaveScope.templateVersion}` : "VISUAL LIBRARY / GLOBAL PRESET"}
size="sm"
onClose={() => setDesignProfileSaveOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setDesignProfileSaveOpen(false)}>Отмена</Button>
<WindowFooterActions>
{designProfileSaveMode === "save" ? (
<>
<Button onClick={() => { void saveDesignProfile("save"); }}>Сохранить</Button>
<Button
disabled={designProfilePublishState === "publishing" || Boolean(designProfiles.find((profile) => profile.id === activeDesignProfileId)?.versions.some((release) => release.version === designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version))}
onClick={() => { void publishDesignProfile(); }}
>{designProfilePublishState === "publishing" ? "Публикуется" : `Опубликовать v${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? ""}`}</Button>
<Button variant="primary" shape="pill" onClick={() => { setDesignProfileName(""); setDesignProfileSaveMode("save-as"); }}>Сохранить как новый</Button>
</>
) : (
<Button variant="primary" shape="pill" disabled={!designProfileName.trim()} onClick={() => { void saveDesignProfile("save-as"); }}>Сохранить как новый</Button>
)}
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<div className="catalog-save-profile-current">
<Icon name={designProfileSaveScope.kind === "page" ? "globe" : "settings"} />
<span>
<strong>{designProfileSaveScope.kind === "page" ? "Фрагмент дизайна страницы" : "Глобальные визуальные настройки"}</strong>
<small>{designProfileSaveScope.kind === "page" ? "В профиль попадут только Map settings и presentation profiles. Camera, bindings, subjects и credentials не сохраняются." : "Обновляются тема, материалы, media, favicon и toolbar. Уже сохранённые фрагменты страниц сохраняются без изменений."}</small>
</span>
</div>
<SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} />
{designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : (
<div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Сохранение создаёт новую draft-версию. Publish фиксирует текущую сохранённую версию неизменяемым release.</small></span></div>
)}
</div>
</Window>
<ConfirmationModal
open={deleteModuleOpen}
title="Удалить модуль?"
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Foundry. Опубликованные releases эта операция не затрагивает.</p></>}
confirmLabel="Удалить draft"
pendingLabel="Удаление…"
danger
onClose={() => setDeleteModuleOpen(false)}
onConfirm={deleteApplicationDraft}
/>
<ConfirmationModal
open={Boolean(pageRemovalId)}
title="Удалить страницу из модуля?"
description={<><strong>{applicationDraft?.pages.find((page) => page.id === pageRemovalId)?.title ?? "Страница"}</strong><p>Экземпляр страницы и его настройки будут удалены из текущей конфигурации. Исходный Page Template останется доступен в библиотеке.</p></>}
confirmLabel="Удалить страницу"
pendingLabel="Удаление…"
danger
onClose={() => setPageRemovalId(null)}
onConfirm={confirmPageRemoval}
/>
<FoundrySettingsModal open={foundrySettingsOpen} isAdmin={isFoundryAdmin} onClose={() => setFoundrySettingsOpen(false)} />
<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)}
/>
<ToastStack items={toasts} onDismiss={dismissToast} />
</>
);
}