feat(foundry): close the operational map data loop

This commit is contained in:
Codex
2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions
+320 -144
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
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 {
@@ -37,6 +37,8 @@ import {
Switch,
TextAreaField,
TextField,
ToastCard,
ToastStack,
Toolbar,
UserProfileMenu,
useApplicationWorkspace,
@@ -45,6 +47,8 @@ import {
WorkspaceWindow,
type IconName,
type ShareAccessMember,
type ToastItem,
type ToastTone,
type ToolbarPlacement,
type WorkspaceWindowRect,
} from "@nodedc/ui-react";
@@ -60,15 +64,38 @@ import {
type DesignProfileSummary,
type DesignProfileStatus,
} from "./applicationManifest.js";
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.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" | "modals" | "icons";
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" },
@@ -77,56 +104,6 @@ const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "Magenta", value: [215, 70, 255], hex: "#d746ff" },
];
type MaterialDraft = {
panelHex: string;
panelOpacity: number;
fieldHex: string;
fieldOpacity: number;
nestedHex: string;
};
interface StoredLayout {
theme?: NodedcTheme;
accentHex?: string;
materialByTheme?: Record<NodedcTheme, MaterialDraft>;
environment?: {
lightColor?: string;
brightness?: number;
glowDistance?: number;
connectionType?: string;
connectionColor?: string;
usePortColors?: boolean;
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeOpacity?: number;
};
media?: {
source?: "file" | "url";
url?: string;
fileName?: string;
fileSrc?: string;
visible?: boolean;
logoSource?: "file" | "url";
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
};
glass?: GlassMaterialSettings;
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
};
}
interface FoundrySessionProfile {
user: {
id: string;
@@ -198,14 +175,20 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
description: "Единый материал только для модальных окон и перемещаемого Inspector.",
icon: "apps",
},
status: {
eyebrow: "04 / STATUS NOTIFICATIONS",
title: "Статусы",
description: "Канонические стеклянные уведомления о сохранении, обновлении и ошибках — стек снизу справа.",
icon: "activity",
},
modals: {
eyebrow: "04 / MODALS",
eyebrow: "05 / MODALS",
title: "Модалки",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard",
},
icons: {
eyebrow: "05 / ICONS",
eyebrow: "06 / ICONS",
title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid",
@@ -236,7 +219,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{
title: "Сущности",
note: "Общий словарь",
icons: ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"],
icons: ["profile", "users", "building", "globe", "target", "database", "network", "inbox", "mail"],
},
{
title: "Контент",
@@ -276,6 +259,7 @@ const iconLabels: Record<IconName, string> = {
minimize: "Свернуть",
network: "Связи",
panel: "Панель",
target: "Таргеты",
plus: "Добавить",
profile: "Профиль",
refresh: "Обновить",
@@ -328,9 +312,13 @@ export function CatalogApp() {
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 [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
@@ -351,6 +339,7 @@ export function CatalogApp() {
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");
@@ -433,6 +422,64 @@ export function CatalogApp() {
{ 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;
@@ -640,6 +687,26 @@ export function CatalogApp() {
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" })
@@ -734,21 +801,8 @@ export function CatalogApp() {
workspace.openView(next as CatalogSection);
};
const openDesignProfile = async (id: string) => {
const openDesignProfile = (id: string) => {
setActiveDesignProfileId(id);
const response = await fetch(`/api/design-profiles/${id}`, { cache: "no-store" });
if (!response.ok) return setLayoutSaveState("error");
const profile = await response.json() as { layout: StoredLayout };
const stored = profile.layout;
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.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);
setLayoutSaveState("saved");
};
const openApplicationDraft = async (id: string) => {
@@ -760,16 +814,47 @@ export function CatalogApp() {
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);
setTheme(manifest.designProfile.theme);
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));
@@ -794,7 +879,12 @@ export function CatalogApp() {
});
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,
@@ -838,12 +928,27 @@ export function CatalogApp() {
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");
@@ -871,31 +976,20 @@ export function CatalogApp() {
...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 saveMapPageTemplate = async () => {
const layout = mapTemplatePreviewRef.current?.getLayout();
if (!layout) {
setMapTemplateSaveState("error");
return;
}
setMapTemplateSaveState("saving");
try {
const response = await fetch("/api/page-layouts/map", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(layout),
});
if (!response.ok) throw new Error("map_page_layout_save_failed");
setMapTemplateLayout(await response.json() as MapPageLayout);
setMapTemplateSaveState("saved");
} catch {
setMapTemplateSaveState("error");
}
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) => {
@@ -1029,45 +1123,88 @@ export function CatalogApp() {
};
const saveDesignProfile = async (mode = designProfileSaveMode) => {
const layoutSaved = await saveEnvironmentLayout();
if (!layoutSaved) return;
const layoutResponse = await fetch("/api/layout", { cache: "no-store" });
if (!layoutResponse.ok) return setLayoutSaveState("error");
const layout = await layoutResponse.json() as StoredLayout;
const creating = mode === "save-as";
const selected = designProfiles.find((profile) => profile.id === activeDesignProfileId);
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) return setLayoutSaveState("error");
const saved = await response.json() as { id: string; name: string; version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { updatedAt: string } };
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);
setDesignProfileSaveOpen(false);
setDesignProfileName("");
setLayoutSaveState("saved");
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 } };
@@ -1078,6 +1215,7 @@ export function CatalogApp() {
}));
setDesignProfilePublishState("published");
setDesignProfileSaveOpen(false);
updateToast(toastId, { tone: "success", title: "Design Profile опубликован", description: `${current.name} · v${release.version}`, durationMs: 4200 });
};
const addPageTemplateToApplication = (template: PageTemplateDefinition) => {
@@ -1448,6 +1586,33 @@ export function CatalogApp() {
</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">
@@ -1468,7 +1633,7 @@ export function CatalogApp() {
</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={() => { setDesignProfileSaveMode("save"); setDesignProfileSaveOpen(true); }}>Открыть сохранение</Button>
<Button shape="pill" icon={<Icon name="save" />} onClick={() => { setDesignProfileSaveScope({ kind: "global" }); setDesignProfileSaveMode("save"); setDesignProfileSaveOpen(true); }}>Открыть сохранение</Button>
</Preview>
</div>
</section>
@@ -1606,16 +1771,7 @@ export function CatalogApp() {
label="Design Profile"
value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`}
options={designProfileOptions}
onChange={(profileReference) => {
const [profileId, version, statusValue] = profileReference.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;
setTheme(profileTheme);
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version, status, theme: profileTheme } }));
}}
onChange={(profileReference) => { void selectApplicationDesignProfile(profileReference); }}
/>
</SettingsCard>
@@ -1682,7 +1838,6 @@ export function CatalogApp() {
</DragDropRoot>
<div className="catalog-page-composer__footer">
<span data-state={applicationSaveState}>{applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "saved" ? "Конфигурация сохранена" : applicationSaveState === "error" ? "Ошибка сохранения" : "Есть несохранённые изменения"}</span>
<Button variant="primary" shape="pill" icon={<Icon name="save" />} onClick={() => { void saveApplicationDraft(); }}>Сохранить конфигурацию</Button>
</div>
</SettingsCard>
{applicationError ? <p className="nodedc-media-field__error" role="alert">{applicationError}</p> : null}
@@ -1690,14 +1845,20 @@ export function CatalogApp() {
);
};
const renderPageTemplate = (template: PageTemplateDefinition) => (
template.id === "map" ? (
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-${mapTemplateLayout?.savedAt ?? "default"}`}
key={`map-template-${activeDesignProfileId}-${mapDesignProfileKey(template.version)}-${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? "draft"}`}
ref={mapTemplatePreviewRef}
expanded
initialLayout={mapTemplateLayout}
initialLayout={mapDesignLayout}
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
/>
<div className="catalog-page-template__actions">
@@ -1736,8 +1897,8 @@ export function CatalogApp() {
</SettingsCard>
</div>
</div>
)
);
);
};
const renderApplicationPage = () => {
if (!applicationDraft || !activeApplicationPageId) return null;
@@ -1745,6 +1906,12 @@ export function CatalogApp() {
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),
@@ -1752,11 +1919,11 @@ export function CatalogApp() {
return (
<div className="catalog-application-page">
<MapFixturePreview
key={page.id}
key={`${page.id}-${applicationDraft.designProfile.id}-${applicationDraft.designProfile.version}-${applicationDraft.designProfile.status}`}
ref={applicationMapPreviewRef}
applicationId={applicationDraft.id}
pageId={page.id}
initialLayout={page.layout?.map ?? null}
initialLayout={resolvedMapLayout}
features={page.features}
expanded
/>
@@ -1866,7 +2033,7 @@ export function CatalogApp() {
emptyLabel="Модули не созданы"
onChange={(id) => { void openApplicationDraft(id); }}
/>
) : studioContext === "visual" ? (
) : studioContext === "visual" || studioContext === "pages" ? (
<Select
className="catalog-module-switcher"
label="Design Profile"
@@ -1918,6 +2085,7 @@ export function CatalogApp() {
onClick: () => {
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
setDesignProfileName(currentProfile?.name ?? "");
setDesignProfileSaveScope({ kind: "global" });
setDesignProfileSaveMode("save");
setDesignProfileSaveOpen(true);
},
@@ -1936,9 +2104,9 @@ export function CatalogApp() {
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
utilityActions={activePageTemplate.id === "map" ? [{
label: mapTemplateSaveState === "saving" ? "Сохраняем layout страницы" : mapTemplateSaveState === "saved" ? "Layout страницы сохранён" : mapTemplateSaveState === "error" ? "Повторить сохранение layout страницы" : "Сохранить layout страницы",
label: mapTemplateSaveState === "saving" ? "Сохраняем Map-фрагмент в Design Profile" : mapTemplateSaveState === "error" ? "Повторить сохранение Map-фрагмента" : "Сохранить дизайн Map в Design Profile",
icon: "save",
onClick: () => { void saveMapPageTemplate(); },
onClick: () => openMapPageDesignSave(activePageTemplate.version),
disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading",
}] : undefined}
onClose={workspace.closeView}
@@ -1954,7 +2122,7 @@ export function CatalogApp() {
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
utilityActions={[{
label: applicationSaveState === "saving" ? "Draft сохраняется" : applicationSaveState === "saved" ? "Draft сохранён" : applicationSaveState === "error" ? "Повторить сохранение draft" : "Сохранить Application Draft",
label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
icon: "save",
onClick: () => { void saveApplicationDraft(); },
disabled: applicationSaveState === "saving" || applicationSaveState === "loading",
@@ -2042,7 +2210,7 @@ export function CatalogApp() {
<Window
open={designProfileSaveOpen}
title="Сохранить Design Profile"
subtitle="VISUAL LIBRARY / PRESET"
subtitle={designProfileSaveScope.kind === "page" ? `PAGE LIBRARY / ${designProfileSaveScope.templateId.toUpperCase()}@${designProfileSaveScope.templateVersion}` : "VISUAL LIBRARY / GLOBAL PRESET"}
size="sm"
onClose={() => setDesignProfileSaveOpen(false)}
footer={
@@ -2066,6 +2234,13 @@ export function CatalogApp() {
}
>
<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>
@@ -2275,6 +2450,7 @@ export function CatalogApp() {
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
/>
<ToastStack items={toasts} onDismiss={dismissToast} />
</>
);
}