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 = { 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 = { 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 = { 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 (
{title} {note ? {note} : null}
{children}
); } export function CatalogApp() { const [theme, setTheme] = useState("dark"); const [accentHex, setAccentHex] = useState("#ff2f92"); const [materialByTheme, setMaterialByTheme] = useState>(() => ({ dark: { ...materialDefaults.dark }, light: { ...materialDefaults.light }, })); const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const [studioContext, setStudioContext] = useState("visual"); const [applicationSummaries, setApplicationSummaries] = useState([]); const [designProfiles, setDesignProfiles] = useState([]); const [activeDesignProfileLayout, setActiveDesignProfileLayout] = useState(null); const [applicationDesignProfileLayout, setApplicationDesignProfileLayout] = useState(null); const [applicationDraft, setApplicationDraft] = useState(null); const [applicationSaveState, setApplicationSaveState] = useState("idle"); const [applicationError, setApplicationError] = useState(""); const [toasts, setToasts] = useState([]); const toastSequenceRef = useRef(0); const [sessionProfile, setSessionProfile] = useState(null); const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false); const [mapTemplateLayout, setMapTemplateLayout] = useState(null); const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const mapTemplatePreviewRef = useRef(null); const applicationMapPreviewRef = useRef(null); const [mapSettingsPanelHost, setMapSettingsPanelHost] = useState(null); const [mapHeaderActionsHost, setMapHeaderActionsHost] = useState(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(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({ kind: "global" }); const [designProfileName, setDesignProfileName] = useState(""); const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle"); const [activeDesignProfileId, setActiveDesignProfileId] = useState("default"); const workspace = useApplicationWorkspace(); 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(null); const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true); const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false); const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState({ 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(null); const pendingMediaFileRef = useRef(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(null); const pendingLogoFileRef = useRef(null); const [glassMaterial, setGlassMaterial] = useState({ ...defaultGlassMaterial }); const [faviconFileName, setFaviconFileName] = useState("icon-adaptive.svg"); const [faviconError, setFaviconError] = useState(""); const [generatedFavicons, setGeneratedFavicons] = useState([]); const [faviconAssets, setFaviconAssets] = useState({ ico: "/favicon/favicon.ico", apple: "/favicon/apple-touch-icon.png", icon192: "/favicon/icon-192.png", icon512: "/favicon/icon-512.png", }); const pendingFaviconIcoRef = useRef(null); const [toolbarPlacement, setToolbarPlacement] = useState("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("editor"); const [shareMessage, setShareMessage] = useState(""); const [shareMembers, setShareMembers] = useState>>([ { 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>) => { 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 : 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) => { 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: ( <> updateMaterial({ panelHex })} /> `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} /> updateMaterial({ fieldHex })} /> `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} /> updateMaterial({ nestedHex })} /> ), }, { id: "toolbar-settings", label: "Рабочее поле — Toolbar", tone: "accent" as const, content: ( <> setToolbarPlacement(value as ToolbarPlacement)} /> `${value}px`} onChange={(value) => { const next = Math.round(value); setToolbarMinSize(next); setToolbarMaxSize((current) => Math.max(current, next)); }} /> `${value}px`} onChange={(value) => setToolbarMaxSize(Math.max(toolbarMinSize, Math.round(value)))} /> `${value} иконок`} onChange={(value) => setToolbarLensCount(Math.round(value) % 2 === 0 ? Math.round(value) + 1 : Math.round(value))} /> ), }, { id: "illumination", label: "Подсветка", tone: "accent" as const, content: ( <> `${value}%`} onChange={setBrightness} /> `${value}%`} onChange={setGlowDistance} /> ), }, { id: "connections", label: "Связи", tone: "accent" as const, content: ( <> ), }, { id: "ports", label: "Порты", tone: "accent" as const, content: ( <> `${value}%`} onChange={setFillOpacity} /> `${value}%`} onChange={setStrokeOpacity} /> ), }, ]; const renderSectionContent = () => { switch (activeSection) { case "controls": return (
{ void handleFaviconFile(event.target.files?.[0]); }} /> {faviconFileName}
{[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
{size} × {size}
; })}
{faviconError ? {faviconError} : null}
); case "glass": return (
CANONICAL GLASS

Модальное окно и Inspector

Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.

setGlassMaterial((current) => ({ ...current, tintHex }))} /> `${value}%`} onChange={(tintOpacity) => setGlassMaterial((current) => ({ ...current, version: 4, tintOpacity }))} /> `${value}px`} onChange={(blur) => setGlassMaterial((current) => ({ ...current, blur }))} /> `${value}%`} onChange={(saturation) => setGlassMaterial((current) => ({ ...current, saturation }))} /> `${value}%`} onChange={(brightness) => setGlassMaterial((current) => ({ ...current, brightness }))} /> `${value}%`} onChange={(outlineOpacity) => setGlassMaterial((current) => ({ ...current, outlineOpacity }))} /> `${value}%`} onChange={(shadowOpacity) => setGlassMaterial((current) => ({ ...current, shadowOpacity }))} />
); case "status": return (
); case "modals": return (
ОбщиеLauncher / Engine

Одна колонка на узком viewport; footer остаётся видимым.

Destructive-цвет появляется только у подтверждения.

Участники, роли, приглашение и immutable-владелец.

Обновляет текущий preset или создаёт новый именованный профиль.

Работа с моделямиBIM Viewer

Read-only ссылка и copy-state.

Действия над выбранным размером или объектом.

Прокручиваемая история BIM-версий.

Контент, вложения и detail-состояние.

); case "icons": return (

Все glyph-иконки используют один размер 16 px и один stroke 1.6; surface меняет только фон и hit target.

{iconGroups.map((group) => (
{group.title}{group.note}
{group.icons.map((name) => (
{iconLabels[name]}{name}
))}
))}
); 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 (
{applicationDraft.pages.length ? applicationDraft.pages.map((page) => {page.navigation.label}) : В приложении пока нет страниц}
{applicationDraft.metadata.name} {applicationDraft.pages.length ? "Выберите страницу в левой панели для полного preview." : "Вернитесь в режим настройки и добавьте готовый Page Template."}
); } return (
updateApplicationDraft((current) => ({ ...current, metadata: { ...current.metadata, name: event.target.value }, }))} /> updateApplicationDraft((current) => ({ ...current, metadata: { ...current.metadata, slug: event.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, "-") }, }))} /> updateApplicationDraft((current) => ({ ...current, metadata: { ...current.metadata, description: event.target.value }, }))} />
({ value: application.id, label: application.name, description: `${application.version} · ${application.status}`, icon: }))} searchable searchPlaceholder="Поиск модулей" emptyLabel="Модули не созданы" onChange={(id) => { void openApplicationDraft(id); }} /> ) : studioContext === "visual" || studioContext === "pages" ? (
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)} /> undefined} onClose={() => setShareLinkOpen(false)} /> setContextActionOpen(false)} footer={} >
setHistoryOpen(false)} >
ФайлВерсияАвторСтатусДействия
{[ ["rail-hub-model.glb", "v12 · текущая", "DC", "Модель готова"], ["rail-hub-model.glb", "v11", "Maria", "Модель готова"], ["rail-hub-model.glb", "v10", "Alex", "Ожидает подготовки"], ].map((row) => (
{row[0]}{row[1]}{row[2]}{row[3]}
))}
setDetailOpen(false)} footer={ <> } >
Конфигурация будет удалена. Это действие нельзя отменить.} confirmLabel="Удалить" danger onClose={() => setConfirmOpen(false)} onConfirm={() => setConfirmOpen(false)} /> ); }