2460 lines
129 KiB
TypeScript
2460 lines
129 KiB
TypeScript
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} />
|
||
</>
|
||
);
|
||
}
|