feat(module-studio): add configurable map page

This commit is contained in:
DCCONSTRUCTIONS
2026-07-13 17:14:34 +03:00
parent 1f2dffc031
commit 1a2ca8c82c
23 changed files with 2700 additions and 31 deletions
+77 -24
View File
@@ -58,6 +58,7 @@ import {
type DesignProfileSummary,
type DesignProfileStatus,
} from "./applicationManifest.js";
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
type StudioContext = "visual" | "pages" | "applications";
@@ -306,6 +307,10 @@ export function CatalogApp() {
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
const [applicationError, setApplicationError] = useState("");
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 [createModuleOpen, setCreateModuleOpen] = useState(false);
const [createModuleName, setCreateModuleName] = useState("");
const [createModuleSlug, setCreateModuleSlug] = useState("");
@@ -496,6 +501,24 @@ export function CatalogApp() {
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" })
@@ -712,13 +735,21 @@ export function CatalogApp() {
const saveApplicationDraft = async () => {
if (!applicationDraft) return;
const activeMapLayout = activeApplicationPageId ? applicationMapPreviewRef.current?.getLayout() : null;
const draftToSave = activeMapLayout && activeApplicationPageId ? {
...applicationDraft,
pages: applicationDraft.pages.map((page) => page.id === activeApplicationPageId ? {
...page,
layout: { ...page.layout, map: activeMapLayout },
} : page),
} : applicationDraft;
setApplicationSaveState("saving");
setApplicationError("");
try {
const response = await fetch(`/api/applications/${applicationDraft.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(applicationDraft),
body: JSON.stringify(draftToSave),
});
if (!response.ok) throw new Error("application_save_failed");
const saved = await response.json() as ApplicationManifestV01;
@@ -743,6 +774,27 @@ export function CatalogApp() {
}
};
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 changeStudioContext = (next: StudioContext) => {
setStudioContext(next);
workspace.openNavigation();
@@ -1504,27 +1556,28 @@ export function CatalogApp() {
};
const renderPageTemplate = (template: PageTemplateDefinition) => (
template.id === "map" ? (
<div className="catalog-page-template catalog-page-template--map">
<MapFixturePreview
key={`map-template-${mapTemplateLayout?.savedAt ?? "default"}`}
ref={mapTemplatePreviewRef}
expanded
initialLayout={mapTemplateLayout}
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
/>
<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}
>
<div className="catalog-map-template-preview" aria-label="Превью Map Page Template">
<div className="catalog-map-template-preview__grid" />
<div className="catalog-map-template-preview__route" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--one" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--two" />
<div className="catalog-map-template-preview__actions">
{template.actions.map((action) => <span key={action.id}><Icon name={action.feature === "assistant" ? "apps" : action.feature === "toolbar" ? "sliders" : "panel"} /></span>)}
</div>
<div className="catalog-map-template-preview__inspector">
<strong>Inspector</strong>
<span>Selection</span>
<span>Layers</span>
<span>Appearance</span>
</div>
</div>
<MapFixturePreview features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))} />
<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>
@@ -1548,6 +1601,7 @@ export function CatalogApp() {
</SettingsCard>
</div>
</div>
)
);
const renderApplicationPage = () => {
@@ -1562,14 +1616,7 @@ export function CatalogApp() {
}));
return (
<div className="catalog-application-page">
<div className="catalog-map-template-preview catalog-map-template-preview--application" aria-label={`Preview ${page.title}`}>
<div className="catalog-map-template-preview__grid" />
<div className="catalog-map-template-preview__route" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--one" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--two" />
{page.features.toolbar ? <div className="catalog-map-template-preview__actions"><span><Icon name="panel" /></span><span><Icon name="sliders" /></span>{page.features.assistant ? <span><Icon name="apps" /></span> : null}</div> : null}
{page.features.inspector ? <div className="catalog-map-template-preview__inspector"><strong>Inspector</strong><span>Selection</span><span>Layers</span><span>Appearance</span></div> : null}
</div>
<MapFixturePreview key={page.id} ref={applicationMapPreviewRef} initialLayout={page.layout?.map ?? null} features={page.features} expanded />
{applicationMode === "edit" ? (
<SettingsCard eyebrow="PAGE SETTINGS" title={page.title} description={`${template.title} · ${template.version}`}>
<div className="catalog-application-draft__features">
@@ -1735,6 +1782,12 @@ export function CatalogApp() {
description={`${activePageTemplate.category} · contract ${activePageTemplate.schemaVersion} · template ${activePageTemplate.version}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
utilityActions={activePageTemplate.id === "map" ? [{
label: mapTemplateSaveState === "saving" ? "Сохраняем layout страницы" : mapTemplateSaveState === "saved" ? "Layout страницы сохранён" : mapTemplateSaveState === "error" ? "Повторить сохранение layout страницы" : "Сохранить layout страницы",
icon: "save",
onClick: () => { void saveMapPageTemplate(); },
disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading",
}] : undefined}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderPageTemplate(activePageTemplate)}</div>