feat(foundry): close the operational map data loop
This commit is contained in:
+320
-144
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user