Build Module Studio application composition
This commit is contained in:
+733
-22
@@ -1,5 +1,6 @@
|
||||
import { 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 {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
ConfirmationModal,
|
||||
ControlRow,
|
||||
Dropdown,
|
||||
DragDropRoot,
|
||||
DraggableItem,
|
||||
DropZone,
|
||||
FieldFrame,
|
||||
GlassSurface,
|
||||
GlassMaterialSurface,
|
||||
@@ -28,6 +32,8 @@ import {
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
SortableItem,
|
||||
SortableScope,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
@@ -41,8 +47,23 @@ import {
|
||||
type ToolbarPlacement,
|
||||
} 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,
|
||||
} from "./applicationManifest.js";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
type StudioView = CatalogSection | `page-template:${string}` | `application:${string}` | `application:${string}/page:${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 accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
|
||||
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
|
||||
@@ -278,8 +299,31 @@ export function CatalogApp() {
|
||||
light: { ...materialDefaults.light },
|
||||
}));
|
||||
const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||||
const workspace = useApplicationWorkspace<CatalogSection>();
|
||||
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
||||
const [studioContext, setStudioContext] = useState<StudioContext>("visual");
|
||||
const [applicationSummaries, setApplicationSummaries] = useState<ApplicationSummary[]>([]);
|
||||
const [designProfiles, setDesignProfiles] = useState<DesignProfileSummary[]>([]);
|
||||
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
|
||||
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
|
||||
const [applicationError, setApplicationError] = useState("");
|
||||
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 [designProfileName, setDesignProfileName] = useState("");
|
||||
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);
|
||||
@@ -450,6 +494,40 @@ export function CatalogApp() {
|
||||
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/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);
|
||||
@@ -524,9 +602,156 @@ export function CatalogApp() {
|
||||
};
|
||||
|
||||
const openSection = (next: string) => {
|
||||
setStudioContext("visual");
|
||||
workspace.openView(next as CatalogSection);
|
||||
};
|
||||
|
||||
const openDesignProfile = async (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) => {
|
||||
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;
|
||||
setApplicationDraft(manifest);
|
||||
setTheme(manifest.designProfile.theme);
|
||||
setApplicationSaveState("saved");
|
||||
} catch {
|
||||
setApplicationDraft(null);
|
||||
setApplicationSaveState("error");
|
||||
setApplicationError("Не удалось открыть Application Draft.");
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
setApplicationDraft(manifest);
|
||||
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;
|
||||
setApplicationSaveState("saving");
|
||||
setApplicationError("");
|
||||
try {
|
||||
const response = await fetch(`/api/applications/${applicationDraft.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(applicationDraft),
|
||||
});
|
||||
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");
|
||||
} catch {
|
||||
setApplicationSaveState("error");
|
||||
setApplicationError("Draft не сохранён. Проверьте название и slug.");
|
||||
}
|
||||
};
|
||||
|
||||
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>) => {
|
||||
@@ -639,11 +864,94 @@ export function CatalogApp() {
|
||||
});
|
||||
if (!response.ok) throw new Error("layout_save_failed");
|
||||
setLayoutSaveState("saved");
|
||||
return true;
|
||||
} catch {
|
||||
setLayoutSaveState("error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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; layout: StoredLayout; timestamps: { updatedAt: string } };
|
||||
const summary: DesignProfileSummary = { id: saved.id, name: saved.name, version: saved.version, theme: saved.layout.theme === "light" ? "light" : "dark", updatedAt: saved.timestamps.updatedAt };
|
||||
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");
|
||||
};
|
||||
|
||||
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",
|
||||
@@ -940,6 +1248,10 @@ export function CatalogApp() {
|
||||
<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={() => { setDesignProfileSaveMode("save"); setDesignProfileSaveOpen(true); }}>Открыть сохранение</Button>
|
||||
</Preview>
|
||||
</div>
|
||||
</section>
|
||||
<section className="catalog-modal-group">
|
||||
@@ -997,7 +1309,237 @@ export function CatalogApp() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderApplicationDraft = () => {
|
||||
if (!applicationDraft || applicationDraft.id !== activeApplicationId) return null;
|
||||
const selectedProfile = designProfiles.find((profile) => profile.id === applicationDraft.designProfile.id) ?? designProfiles[0];
|
||||
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={selectedProfile?.id ?? "default"}
|
||||
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}` }))}
|
||||
onChange={(profileId) => {
|
||||
const profile = designProfiles.find((item) => item.id === profileId);
|
||||
if (!profile) return;
|
||||
setTheme(profile.theme);
|
||||
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version: profile.version, theme: profile.theme } }));
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPageTemplate = (template: PageTemplateDefinition) => (
|
||||
<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>
|
||||
<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 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">
|
||||
<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>
|
||||
{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 (
|
||||
<>
|
||||
@@ -1015,10 +1557,14 @@ export function CatalogApp() {
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl={headerMarkSrc} />
|
||||
<HeaderNavigation
|
||||
label="Навигация приложения"
|
||||
value={guidelineOpen ? "guideline" : undefined}
|
||||
items={[{ value: "guideline", label: "Guideline" }]}
|
||||
onChange={() => guidelineOpen ? closeGuideline() : workspace.openNavigation()}
|
||||
label="Рабочая область Module Studio"
|
||||
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"
|
||||
@@ -1046,29 +1592,66 @@ export function CatalogApp() {
|
||||
<div className="catalog-launcher-stage__shade" />
|
||||
<div className="catalog-launcher-stage__title">
|
||||
<span>NODE.DC</span>
|
||||
<strong>DESIGN<br />GUIDELINE</strong>
|
||||
<p>Общий форм-фактор приложений, компоненты и оконная механика платформы.</p>
|
||||
<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="Visual Guideline"
|
||||
closeLabel="Закрыть Guideline"
|
||||
navigationLabel="Разделы Guideline"
|
||||
title={studioContext === "applications" ? "Applications" : studioContext === "pages" ? "Page Library" : "Visual Library"}
|
||||
closeLabel="Закрыть Module Studio"
|
||||
navigationLabel={studioContext === "applications" ? "Application Drafts" : studioContext === "pages" ? "Page Templates" : "Разделы Visual Library"}
|
||||
onClose={closeGuideline}
|
||||
items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
|
||||
id,
|
||||
label: item.title,
|
||||
icon: <Icon name={item.icon} />,
|
||||
}))}
|
||||
activeId={activeSection ?? undefined}
|
||||
onItemChange={openSection}
|
||||
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" ? (
|
||||
<Select
|
||||
className="catalog-module-switcher"
|
||||
label="Design Profile"
|
||||
value={activeDesignProfileId}
|
||||
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}`, 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="shield" /></span>
|
||||
<span>Design System 0.6.0</span>
|
||||
<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 System 0.6.0"}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -1084,17 +1667,61 @@ export function CatalogApp() {
|
||||
utilityActions={[{
|
||||
label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
|
||||
icon: "save",
|
||||
onClick: () => { void saveEnvironmentLayout(); },
|
||||
onClick: () => {
|
||||
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
|
||||
setDesignProfileName(currentProfile?.name ?? "");
|
||||
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}
|
||||
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}
|
||||
utilityActions={[{
|
||||
label: applicationSaveState === "saving" ? "Draft сохраняется" : applicationSaveState === "saved" ? "Draft сохранён" : applicationSaveState === "error" ? "Повторить сохранение draft" : "Сохранить Application Draft",
|
||||
icon: "save",
|
||||
onClick: () => { void saveApplicationDraft(); },
|
||||
disabled: applicationSaveState === "saving" || applicationSaveState === "loading",
|
||||
}, {
|
||||
label: "Удалить модуль",
|
||||
icon: "trash",
|
||||
onClick: () => setDeleteModuleOpen(true),
|
||||
}]}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
<div className="catalog-panel-content">
|
||||
<div className="catalog-module-mode">
|
||||
<SegmentedControl label="Режим модуля" value={applicationMode} items={[{ value: "edit", label: "Настройка" }, { value: "preview", label: "Предпросмотр" }]} onChange={setApplicationMode} />
|
||||
</div>
|
||||
{activeApplicationPage ? renderApplicationPage() : renderApplicationDraft()}
|
||||
</div>
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{guidelineOpen ? (
|
||||
{guidelineOpen && studioContext === "visual" ? (
|
||||
<Toolbar<CatalogSection>
|
||||
placement={toolbarPlacement}
|
||||
background={toolbarBg}
|
||||
@@ -1126,6 +1753,90 @@ export function CatalogApp() {
|
||||
<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="VISUAL LIBRARY / PRESET"
|
||||
size="sm"
|
||||
onClose={() => setDesignProfileSaveOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDesignProfileSaveOpen(false)}>Отмена</Button>
|
||||
<WindowFooterActions>
|
||||
{designProfileSaveMode === "save" ? (
|
||||
<>
|
||||
<Button onClick={() => { void saveDesignProfile("save"); }}>Сохранить</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">
|
||||
<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-версия текущего профиля.</small></span></div>
|
||||
)}
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteModuleOpen}
|
||||
title="Удалить модуль?"
|
||||
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Studio. Опубликованные 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}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={createModalOpen}
|
||||
title="Создать проект"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { NodedcTheme } from "@nodedc/ui-core";
|
||||
|
||||
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
||||
|
||||
export type ApplicationDraftStatus = "draft";
|
||||
|
||||
export interface ApplicationPageManifest {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
template: {
|
||||
id: string;
|
||||
version: string;
|
||||
};
|
||||
navigation: {
|
||||
visible: boolean;
|
||||
label: string;
|
||||
order: number;
|
||||
};
|
||||
features: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ApplicationManifestV01 {
|
||||
schemaVersion: typeof applicationManifestSchemaVersion;
|
||||
id: string;
|
||||
status: ApplicationDraftStatus;
|
||||
version: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
};
|
||||
designProfile: {
|
||||
id: string;
|
||||
version: string;
|
||||
theme: NodedcTheme;
|
||||
};
|
||||
pages: ApplicationPageManifest[];
|
||||
favicon: {
|
||||
source: "design-profile";
|
||||
};
|
||||
timestamps: {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplicationSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: ApplicationDraftStatus;
|
||||
version: string;
|
||||
theme: NodedcTheme;
|
||||
pageCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DesignProfileSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
theme: NodedcTheme;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ApplicationDraftSaveState = "idle" | "loading" | "saving" | "saved" | "error";
|
||||
|
||||
export function applicationViewId(id: string) {
|
||||
return `application:${id}` as const;
|
||||
}
|
||||
|
||||
export function applicationPageViewId(applicationId: string, pageId: string) {
|
||||
return `application:${applicationId}/page:${pageId}` as const;
|
||||
}
|
||||
|
||||
export function applicationIdFromView(view: string | null) {
|
||||
if (!view?.startsWith("application:")) return null;
|
||||
return view.slice("application:".length).split("/page:")[0] || null;
|
||||
}
|
||||
|
||||
export function applicationPageIdFromView(view: string | null) {
|
||||
if (!view?.includes("/page:")) return null;
|
||||
return view.split("/page:")[1] || null;
|
||||
}
|
||||
@@ -231,6 +231,327 @@ textarea {
|
||||
padding: 0.75rem 1rem 1.75rem;
|
||||
}
|
||||
|
||||
.catalog-page-template {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-map-template-preview {
|
||||
position: relative;
|
||||
min-height: 26rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background:
|
||||
radial-gradient(circle at 68% 34%, color-mix(in srgb, var(--catalog-accent) 34%, transparent) 0 0.45rem, transparent 0.5rem),
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--nodedc-nested-surface) 90%, #274438), var(--nodedc-nested-surface));
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.22;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--nodedc-text-muted) 30%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--nodedc-text-muted) 30%, transparent) 1px, transparent 1px);
|
||||
background-size: 2.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__route {
|
||||
position: absolute;
|
||||
inset: 28% 24% 22% 18%;
|
||||
border: 3px solid var(--catalog-accent);
|
||||
border-block-color: transparent;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__point {
|
||||
position: absolute;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border: 3px solid var(--nodedc-text-on-accent);
|
||||
border-radius: 50%;
|
||||
background: var(--catalog-accent);
|
||||
box-shadow: 0 0 0 0.5rem color-mix(in srgb, var(--catalog-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__point--one { top: 34%; left: 42%; }
|
||||
.catalog-map-template-preview__point--two { right: 31%; bottom: 28%; }
|
||||
|
||||
.catalog-map-template-preview__actions {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__actions span {
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__inspector {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
display: grid;
|
||||
width: min(18rem, 38%);
|
||||
align-content: start;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--nodedc-glass-border);
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-glass-panel-surface);
|
||||
box-shadow: var(--nodedc-glass-shadow);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(var(--nodedc-glass-blur));
|
||||
}
|
||||
|
||||
.catalog-map-template-preview__inspector span {
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 0.85rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.catalog-page-template__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.catalog-page-template__actions > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.catalog-page-template__list {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.catalog-page-template__list > div {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-page-template__list > div > svg {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.catalog-page-template__list span {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
}
|
||||
|
||||
.catalog-page-template__list small,
|
||||
.catalog-page-template__list code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.catalog-module-switcher,
|
||||
.catalog-module-switcher .nodedc-select-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.catalog-module-switcher .nodedc-select-trigger {
|
||||
min-height: 3.55rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-item-active-bg);
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.catalog-module-mode {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.catalog-module-preview {
|
||||
display: grid;
|
||||
min-height: 38rem;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-nested-surface);
|
||||
}
|
||||
|
||||
.catalog-module-preview__navigation {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
}
|
||||
|
||||
.catalog-module-preview__navigation span {
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 0.7rem 1rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
}
|
||||
|
||||
.catalog-module-preview__stage {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalog-module-preview__stage > svg { width: 2rem; height: 2rem; }
|
||||
.catalog-module-preview__stage strong { color: var(--nodedc-text-primary); font-size: 1.35rem; }
|
||||
|
||||
.catalog-page-composer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-page-composer__column {
|
||||
display: grid;
|
||||
min-height: 21rem;
|
||||
grid-template-rows: auto 1fr;
|
||||
align-content: start;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
|
||||
.catalog-page-composer__column.is-drag-over { background: var(--nodedc-panel-item-hover-bg); }
|
||||
|
||||
.catalog-page-composer__column > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-page-composer__column > header > span {
|
||||
display: grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.catalog-page-composer__column > header > div,
|
||||
.catalog-page-composer__row > span,
|
||||
.catalog-save-profile-current span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.catalog-page-composer__column small,
|
||||
.catalog-page-composer__list small,
|
||||
.catalog-save-profile-current small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.catalog-page-composer__list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.catalog-page-composer__draggable { min-width: 0; }
|
||||
|
||||
.catalog-page-composer__row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 3.55rem;
|
||||
align-items: center;
|
||||
gap: 0.86rem;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 5px 0.72rem 5px 5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.catalog-page-composer__row > span:nth-child(2) { flex: 1 1 auto; }
|
||||
|
||||
.catalog-page-composer__icon {
|
||||
display: grid !important;
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
flex: 0 0 2.92rem;
|
||||
place-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-icon-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.catalog-page-composer__draggable.is-dragging { position: relative; z-index: 8; }
|
||||
|
||||
.catalog-page-composer__row .nodedc-icon-button {
|
||||
width: var(--nodedc-icon-button-size);
|
||||
height: var(--nodedc-icon-button-size);
|
||||
flex-basis: var(--nodedc-icon-button-size);
|
||||
}
|
||||
|
||||
.catalog-page-composer__empty {
|
||||
display: grid;
|
||||
min-height: 9rem;
|
||||
place-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalog-page-composer__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.catalog-page-composer__footer > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.catalog-page-composer__footer > span[data-state="saved"] { color: var(--nodedc-status-success); }
|
||||
|
||||
.catalog-save-profile-current {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-map-template-preview--application { min-height: 42rem; }
|
||||
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -316,6 +637,62 @@ textarea {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-application-draft {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-application-draft__fields {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 0.55fr);
|
||||
}
|
||||
|
||||
.catalog-application-draft__fields .nodedc-field-frame:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.catalog-application-draft__readonly,
|
||||
.catalog-application-draft__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-application-draft__readonly > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.catalog-application-draft__readonly small,
|
||||
.catalog-application-draft__status code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.catalog-application-draft__features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-application-draft__status {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.catalog-application-draft__status > span {
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
}
|
||||
|
||||
.catalog-application-draft__status > span[data-state="saved"] {
|
||||
color: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.catalog-application-draft__status > span[data-state="error"] {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.catalog-modal-action-list,
|
||||
.catalog-detail-modal {
|
||||
display: grid;
|
||||
@@ -549,6 +926,7 @@ textarea {
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.catalog-page-composer { grid-template-columns: 1fr; }
|
||||
.catalog-icon-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -610,6 +988,15 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-application-draft__fields,
|
||||
.catalog-application-draft__features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-application-draft__fields .nodedc-field-frame:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.catalog-window-actions-demo .nodedc-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user