Build Module Studio application composition
This commit is contained in:
parent
d3e2859ca1
commit
50718a33a1
|
|
@ -9,6 +9,7 @@
|
|||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/page-patterns": "0.1.0",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-react": "0.6.0",
|
||||
"react": "^19.1.0",
|
||||
|
|
|
|||
|
|
@ -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]) => ({
|
||||
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={activeSection ?? undefined}
|
||||
onItemChange={openSection}
|
||||
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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,16 @@ Pill navigation для верхней панели и компактного п
|
|||
|
||||
На desktop открытая панель занимает отдельную колонку с Launcher page gap `20 px` до content/stage, а не перекрывает их затемнённым overlay.
|
||||
|
||||
`AdminNavigationPanel` поддерживает сортируемые route-items через общий Drag & Drop contract. Приложение передаёт новый порядок id, а панель использует каноническую шеститочечную ручку Engine. Порядок не хранится отдельно внутри navigation-компонента.
|
||||
|
||||
## Drag & Drop
|
||||
|
||||
`DragHandle`, `DragDropRoot`, `DraggableItem`, `DropZone`, `SortableScope`, `SortableItem` и `SortableList` образуют общий React-контракт переноса и сортировки. Он перенесён из рабочего Engine-паттерна: drag начинается только за шесть точек и только после движения на `6 px`, поэтому обычный клик по строке не конфликтует с навигацией.
|
||||
|
||||
Sortable-строки ограничены вертикальной осью: горизонтальное движение указателя не сдвигает navigation или composition layout. `DragDropRoot` возвращает реальные active/over rectangles, поэтому consumer может принять перенос только после достаточного перекрытия destination. Module Studio использует порог `20%` ширины переносимой строки: короткое движение, движение влево или касание границы не создаёт page instance.
|
||||
|
||||
В Module Studio исходная строка Page Template остаётся в Page Library после переноса. Каждый подтверждённый drop создаёт новый page instance с уникальным id; состав приложения и левая навигация читают один массив страниц и потому всегда перестраиваются синхронно. Удаление instance выполняется стандартным `ConfirmationModal`, а не мгновенным действием крестика.
|
||||
|
||||
## Icon
|
||||
|
||||
`Icon` предоставляет только подтверждённый общий subset иконок. Каноническое имя описывает смысл (`close`, `expand`, `refresh`), а не конкретный путь SVG. Default glyph — `16 px`, stroke — `1.6`; размер не уменьшается дополнительным CSS-scale. В profile-group шапки допустим подтверждённый Launcher-размер `20 px`. Размеры surface/hit target при этом остаются независимыми и не уменьшаются.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
# NDC Module Studio
|
||||
|
||||
Текущий living catalog эволюционирует в Module Studio без создания второго визуального приложения. Существующие разделы остаются `Visual Library`; рядом живут `Page Library` и `Applications`. Первый вертикальный срез не подключает Engine, Cesium, Authentik, Hub или Deploy.
|
||||
|
||||
## Page Template contract v0.1
|
||||
|
||||
Канонические TypeScript-контракты и manifests находятся в `@nodedc/page-patterns`, машинно-читаемый реестр — в `registry/pages.json`.
|
||||
|
||||
Первый шаблон `Map Page 0.1.0` формально фиксирует:
|
||||
|
||||
- template identity и pinned version;
|
||||
- стартовую страницу и navigation defaults;
|
||||
- разрешённые feature flags `Inspector`, `Toolbar`, `Assistant`;
|
||||
- runtime-neutral slots `points`, `traces`, `routes`, `zones`, `selection`, `commands`, `assistant`;
|
||||
- template-owned actions.
|
||||
|
||||
Шаблон не содержит Cesium token, Engine workflow, React component tree или свободные координаты элементов. Renderer/Cesium adapter и Capability Bindings являются отдельными следующими слоями.
|
||||
|
||||
## Application Manifest v0.1
|
||||
|
||||
Каноническая JSON Schema: `registry/schemas/application-manifest-v0.1.schema.json`.
|
||||
|
||||
Manifest фиксирует:
|
||||
|
||||
- identity, slug, draft status и версию;
|
||||
- ссылку на Design Profile и Dark/Light;
|
||||
- хотя бы одну страницу и pinned page-template version;
|
||||
- navigation visibility;
|
||||
- только заранее разрешённые feature flags;
|
||||
- favicon source;
|
||||
- server-controlled timestamps.
|
||||
|
||||
Manifest не хранит runtime credentials, capability bindings, arbitrary component tree, свободные координаты элементов или deployment secrets.
|
||||
|
||||
## Draft lifecycle
|
||||
|
||||
Catalog server предоставляет минимальный временный API:
|
||||
|
||||
- `GET /api/applications` — список drafts;
|
||||
- `GET /api/page-templates` — получить канонический page registry;
|
||||
- `POST /api/applications` — создать draft из явно выбранных `templateId` и `templateVersion`;
|
||||
- `GET /api/applications/:id` — открыть draft;
|
||||
- `PUT /api/applications/:id` — атомарно сохранить draft.
|
||||
|
||||
Файлы находятся в `runtime-data/applications` и исключены из Git. Этот store доказывает lifecycle `create → save → reload → open`; целевой production store позже принадлежит Platform `application-core`.
|
||||
|
||||
## Согласованный UX Application Projects
|
||||
|
||||
- круглая кнопка `+` в заголовке Applications открывает модалку создания модуля;
|
||||
- текущий модуль выбирается через Hub-style searchable selector, а не через плоский список проектов;
|
||||
- root модуля содержит metadata, ссылку на один pinned Design Profile и Application Composition;
|
||||
- Dark/Light, favicon, Glass и прочие визуальные параметры не настраиваются внутри модуля;
|
||||
- две равные колонки `Доступные страницы / Конфигурация приложения` работают только с готовыми Page Templates;
|
||||
- drag-and-drop меняет состав и порядок ссылок на шаблоны, но не создаёт свободный canvas;
|
||||
- после сохранения страницы появляются в левой навигации и открывают визуальный preview;
|
||||
- режимы `Настройка / Предпросмотр` разделяют Studio controls и поведение будущего приложения;
|
||||
- удаление draft выполняется только через destructive confirmation modal.
|
||||
|
||||
## Design Profile lifecycle
|
||||
|
||||
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый`. Приложение хранит только pinned `profile id + version + theme`; media, favicon и material settings принадлежат Design Profile.
|
||||
|
||||
Временный catalog server предоставляет:
|
||||
|
||||
- `GET /api/design-profiles`;
|
||||
- `GET /api/design-profiles/:id`;
|
||||
- `POST /api/design-profiles`;
|
||||
- `PUT /api/design-profiles/:id`;
|
||||
- `DELETE /api/applications/:id` с переносом draft в локальное deleted-storage.
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"name": "@nodedc/ui-catalog",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@nodedc/page-patterns": "0.1.0",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-react": "0.6.0",
|
||||
"react": "^19.1.0",
|
||||
|
|
@ -322,6 +323,59 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
|
|
@ -814,6 +868,10 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/page-patterns": {
|
||||
"resolved": "packages/page-patterns",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/tokens": {
|
||||
"resolved": "packages/tokens",
|
||||
"link": true
|
||||
|
|
@ -1752,6 +1810,12 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
|
@ -1886,6 +1950,10 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"packages/page-patterns": {
|
||||
"name": "@nodedc/page-patterns",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"packages/tokens": {
|
||||
"name": "@nodedc/tokens",
|
||||
"version": "0.6.0"
|
||||
|
|
@ -1908,6 +1976,9 @@
|
|||
"name": "@nodedc/ui-react",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/ui-catalog",
|
||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react",
|
||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog",
|
||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry",
|
||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@nodedc/page-patterns",
|
||||
"version": "0.1.0",
|
||||
"description": "Runtime-neutral NODE.DC page template contracts and canonical template manifests.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
export type PageTemplateCategory = "map" | "assistant" | "analytics" | "monitoring" | "administration" | "workspace";
|
||||
|
||||
export type PageTemplateFeature = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultVisible: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PageTemplateSlotKind = "entity-stream" | "trace-stream" | "route-stream" | "zone-stream" | "selection" | "command" | "assistant";
|
||||
|
||||
export type PageTemplateSlot = {
|
||||
id: string;
|
||||
kind: PageTemplateSlotKind;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
};
|
||||
|
||||
export type PageTemplateAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
feature?: string;
|
||||
};
|
||||
|
||||
export type PageTemplateDefinition = {
|
||||
schemaVersion: "0.1.0";
|
||||
id: string;
|
||||
version: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PageTemplateCategory;
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
navigationLabel: string;
|
||||
};
|
||||
features: PageTemplateFeature[];
|
||||
slots: PageTemplateSlot[];
|
||||
actions: PageTemplateAction[];
|
||||
};
|
||||
|
||||
export const mapPageTemplate = {
|
||||
schemaVersion: "0.1.0",
|
||||
id: "map",
|
||||
version: "0.1.0",
|
||||
title: "Map Page",
|
||||
description: "Full-map NODE.DC page with fixed system actions, Inspector, Toolbar and typed spatial data slots.",
|
||||
category: "map",
|
||||
page: {
|
||||
id: "map",
|
||||
title: "Map",
|
||||
path: "/",
|
||||
navigationLabel: "Map",
|
||||
},
|
||||
features: [
|
||||
{ id: "inspector", label: "Inspector", description: "Canonical draggable Glass Inspector for map settings and selected entities.", defaultVisible: true, required: true },
|
||||
{ id: "toolbar", label: "Toolbar", description: "Canonical positional Toolbar with template-owned actions.", defaultVisible: true },
|
||||
{ id: "assistant", label: "Assistant", description: "Floating NODE.DC assistant entry point and overlay slot.", defaultVisible: false },
|
||||
],
|
||||
slots: [
|
||||
{ id: "points", kind: "entity-stream", label: "Points", description: "Live point entities with position and semantic state.", required: false, multiple: true },
|
||||
{ id: "traces", kind: "trace-stream", label: "Traces", description: "Time-ordered entity traces.", required: false, multiple: true },
|
||||
{ id: "routes", kind: "route-stream", label: "Routes", description: "Planned or computed routes.", required: false, multiple: true },
|
||||
{ id: "zones", kind: "zone-stream", label: "Zones", description: "Polygons, geofences and operational areas.", required: false, multiple: true },
|
||||
{ id: "selection", kind: "selection", label: "Selection", description: "Selected map entity and contextual state.", required: false, multiple: false },
|
||||
{ id: "commands", kind: "command", label: "Commands", description: "Capability-backed actions for selected entities.", required: false, multiple: true },
|
||||
{ id: "assistant", kind: "assistant", label: "Assistant context", description: "Context exposed to the assistant overlay.", required: false, multiple: false },
|
||||
],
|
||||
actions: [
|
||||
{ id: "open-inspector", label: "Inspector", command: "map.inspector.open", feature: "inspector" },
|
||||
{ id: "toggle-toolbar", label: "Toolbar", command: "map.toolbar.toggle", feature: "toolbar" },
|
||||
{ id: "open-assistant", label: "Assistant", command: "assistant.open", feature: "assistant" },
|
||||
],
|
||||
} as const satisfies PageTemplateDefinition;
|
||||
|
||||
export const pageTemplates = [mapPageTemplate] as const satisfies readonly PageTemplateDefinition[];
|
||||
|
||||
export function getPageTemplate(templateId: string, version?: string) {
|
||||
return pageTemplates.find((template) => template.id === templateId && (!version || template.version === version));
|
||||
}
|
||||
|
||||
export function createTemplateFeatures(template: PageTemplateDefinition) {
|
||||
return Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]));
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1411,9 +1411,6 @@ textarea.nodedc-field__control {
|
|||
}
|
||||
|
||||
.nodedc-admin-panel__close {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: grid;
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
|
|
@ -1426,6 +1423,33 @@ textarea.nodedc-field__control {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__head-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__head-actions > .nodedc-icon-button {
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
flex-basis: 2.92rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__context-slot {
|
||||
display: grid;
|
||||
width: calc(100% + 2.2rem);
|
||||
margin-inline: -1.1rem;
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__context-slot .nodedc-select-anchor,
|
||||
.nodedc-admin-panel__context-slot .nodedc-select-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__close:hover {
|
||||
border-color: var(--nodedc-panel-action-border-hover);
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
|
|
@ -1625,6 +1649,50 @@ textarea.nodedc-field__control {
|
|||
color: var(--nodedc-panel-active-icon-color);
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-nav {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-row .nodedc-admin-panel__nav-item {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-row > .nodedc-drag-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.72rem;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.nodedc-drag-handle {
|
||||
display: inline-grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.nodedc-drag-handle:hover {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-drag-handle:active { cursor: grabbing; }
|
||||
.nodedc-drag-handle svg { fill: currentColor; }
|
||||
|
||||
.nodedc-admin-panel__footer {
|
||||
width: calc(100% + 2.2rem);
|
||||
margin-inline: -1.1rem;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@
|
|||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { SortableList } from "./DragDrop.js";
|
||||
|
||||
export interface AdminNavigationContext {
|
||||
id: string;
|
||||
|
|
@ -16,12 +17,15 @@ export interface AdminNavigationItem {
|
|||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
contexts?: AdminNavigationContext[];
|
||||
headerActions?: ReactNode;
|
||||
contextSlot?: ReactNode;
|
||||
items: AdminNavigationItem[];
|
||||
activeId?: string;
|
||||
footer?: ReactNode;
|
||||
|
|
@ -29,12 +33,15 @@ export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLEleme
|
|||
navigationLabel?: string;
|
||||
onClose: () => void;
|
||||
onItemChange: (id: string) => void;
|
||||
onItemsReorder?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function AdminNavigationPanel({
|
||||
eyebrow = "NODE.DC",
|
||||
title,
|
||||
contexts = [],
|
||||
headerActions,
|
||||
contextSlot,
|
||||
items,
|
||||
activeId,
|
||||
footer,
|
||||
|
|
@ -42,21 +49,27 @@ export function AdminNavigationPanel({
|
|||
navigationLabel = "Разделы администрирования",
|
||||
onClose,
|
||||
onItemChange,
|
||||
onItemsReorder,
|
||||
className,
|
||||
...props
|
||||
}: AdminNavigationPanelProps) {
|
||||
return (
|
||||
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 ? "true" : undefined} {...props}>
|
||||
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 || contextSlot ? "true" : undefined} {...props}>
|
||||
<header className="nodedc-admin-panel__head">
|
||||
<div>
|
||||
<p>{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<div className="nodedc-admin-panel__head-actions">
|
||||
{headerActions}
|
||||
<button type="button" className="nodedc-admin-panel__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<span className="nodedc-close-mark" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{contextSlot ? <div className="nodedc-admin-panel__context-slot">{contextSlot}</div> : null}
|
||||
|
||||
{contexts.length > 0 ? <div className="nodedc-admin-panel__contexts">
|
||||
{contexts.map((context) => (
|
||||
<div className="nodedc-admin-panel__context-group" key={context.id}>
|
||||
|
|
@ -83,7 +96,7 @@ export function AdminNavigationPanel({
|
|||
</div> : null}
|
||||
|
||||
<nav className="nodedc-admin-panel__nav" aria-label={navigationLabel}>
|
||||
{items.map((item) => (
|
||||
{items.filter((item) => !item.sortable).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
|
|
@ -95,6 +108,29 @@ export function AdminNavigationPanel({
|
|||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{items.some((item) => item.sortable) ? (
|
||||
<SortableList
|
||||
className="nodedc-admin-panel__sortable-nav"
|
||||
items={items.filter((item) => item.sortable)}
|
||||
getId={(item) => item.id}
|
||||
onReorder={(next) => onItemsReorder?.(next.map((item) => item.id))}
|
||||
>
|
||||
{(item, { handle }) => (
|
||||
<div className="nodedc-admin-panel__sortable-row">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-admin-panel__nav-item"
|
||||
data-active={activeId === item.id ? "true" : undefined}
|
||||
onClick={() => onItemChange(item.id)}
|
||||
>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{handle}
|
||||
</div>
|
||||
)}
|
||||
</SortableList>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{footer ? <footer className="nodedc-admin-panel__footer">{footer}</footer> : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import type { ReactNode } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type NodedcDragData = Record<string, unknown>;
|
||||
|
||||
export interface DragRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DragDropResult {
|
||||
activeId: string;
|
||||
overId: string | null;
|
||||
activeData?: NodedcDragData;
|
||||
overData?: NodedcDragData;
|
||||
activeRect?: DragRect;
|
||||
overRect?: DragRect;
|
||||
}
|
||||
|
||||
const copyRect = (rect: DragRect | null | undefined): DragRect | undefined => rect ? ({
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}) : undefined;
|
||||
|
||||
export function DragHandle({ label = "Перетащить", listeners, attributes, className }: {
|
||||
label?: string;
|
||||
listeners?: ReturnType<typeof useDraggable>["listeners"];
|
||||
attributes?: ReturnType<typeof useDraggable>["attributes"];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("nodedc-drag-handle", className)}
|
||||
aria-label={label}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="0.75" /><circle cx="8" cy="4" r="0.75" /><circle cx="12" cy="4" r="0.75" />
|
||||
<circle cx="4" cy="8" r="0.75" /><circle cx="8" cy="8" r="0.75" /><circle cx="12" cy="8" r="0.75" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DragDropRoot({ children, onDragEnd }: { children: ReactNode; onDragEnd: (result: DragDropResult) => void }) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
onDragEnd({
|
||||
activeId: String(event.active.id),
|
||||
overId: event.over ? String(event.over.id) : null,
|
||||
activeData: event.active.data.current as NodedcDragData | undefined,
|
||||
overData: event.over?.data.current as NodedcDragData | undefined,
|
||||
activeRect: copyRect(event.active.rect.current.translated),
|
||||
overRect: copyRect(event.over?.rect),
|
||||
});
|
||||
};
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>{children}</DndContext>;
|
||||
}
|
||||
|
||||
export function DraggableItem({ id, data, className, children }: {
|
||||
id: string;
|
||||
data?: NodedcDragData;
|
||||
className?: string;
|
||||
children: (state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, data });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isDragging && "is-dragging")}
|
||||
style={{ transform: CSS.Translate.toString(transform), opacity: isDragging ? 0.65 : undefined }}
|
||||
>
|
||||
{children({ handle: <DragHandle listeners={listeners} attributes={attributes} />, isDragging })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropZone({ id, data, className, children }: { id: string; data?: NodedcDragData; className?: string; children: ReactNode }) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id, data });
|
||||
return <div ref={setNodeRef} className={cn(className, isOver && "is-drag-over")}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SortableScope({ ids, children }: { ids: string[]; children: ReactNode }) {
|
||||
return <SortableContext items={ids} strategy={verticalListSortingStrategy}>{children}</SortableContext>;
|
||||
}
|
||||
|
||||
export function SortableItem({ id, data, className, children }: {
|
||||
id: string;
|
||||
data?: NodedcDragData;
|
||||
className?: string;
|
||||
children: (state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, data });
|
||||
const verticalTransform = transform ? { ...transform, x: 0 } : null;
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isDragging && "is-dragging")}
|
||||
style={{ transform: CSS.Transform.toString(verticalTransform), transition, opacity: isDragging ? 0.65 : undefined }}
|
||||
>
|
||||
{children({ handle: <DragHandle listeners={listeners} attributes={attributes} />, isDragging })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableList<T>({ items, getId, className, onReorder, children }: {
|
||||
items: T[];
|
||||
getId: (item: T) => string;
|
||||
className?: string;
|
||||
onReorder: (items: T[]) => void;
|
||||
children: (item: T, state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const ids = items.map(getId);
|
||||
return (
|
||||
<DragDropRoot onDragEnd={({ activeId, overId }) => {
|
||||
if (!overId || activeId === overId) return;
|
||||
const from = ids.indexOf(activeId);
|
||||
const to = ids.indexOf(overId);
|
||||
if (from >= 0 && to >= 0) onReorder(arrayMove(items, from, to));
|
||||
}}>
|
||||
<SortableScope ids={ids}>
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<SortableItem key={getId(item)} id={getId(item)}>
|
||||
{(state) => children(item, state)}
|
||||
</SortableItem>
|
||||
))}
|
||||
</div>
|
||||
</SortableScope>
|
||||
</DragDropRoot>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export * from "./Button.js";
|
|||
export * from "./Checker.js";
|
||||
export * from "./ConfirmationModal.js";
|
||||
export * from "./Dropdown.js";
|
||||
export * from "./DragDrop.js";
|
||||
export * from "./Field.js";
|
||||
export * from "./Glass.js";
|
||||
export * from "./Inspector.js";
|
||||
|
|
|
|||
|
|
@ -291,6 +291,23 @@
|
|||
"domContract": ["nodedc-status"],
|
||||
"summary": "Semantic status pill whose tone is independent from the application accent."
|
||||
},
|
||||
{
|
||||
"id": "drag-drop",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["DragHandle", "DragDropRoot", "DraggableItem", "DropZone", "SortableScope", "SortableItem", "SortableList"],
|
||||
"domContract": ["nodedc-drag-handle"],
|
||||
"summary": "Engine-derived handle-only drag, transfer and sortable primitives with the canonical six-dot grip and a 6 px activation threshold.",
|
||||
"behavior": ["handle-only drag", "cross-list transfer with explicit overlap acceptance", "vertical-axis reorder", "drop-zone state", "keyboard attributes from dnd-kit"],
|
||||
"rules": [
|
||||
"The six-dot handle is the only drag initiator; clicking the row keeps its normal navigation or selection action.",
|
||||
"Sortable rows are visually constrained to the vertical axis and cannot pull their layout horizontally.",
|
||||
"A transfer is accepted only when the dragged surface overlaps the destination by at least the consumer-defined threshold; Module Studio uses 20 percent.",
|
||||
"Consumers own domain mutations while the package owns sensors, transforms, accessible drag attributes and geometry.",
|
||||
"Repeated page-template drops create distinct application page instances instead of consuming the source template.",
|
||||
"A reordered application page array is the single source of truth for both composition and navigation order."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inspector",
|
||||
"status": "baseline",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"templates": [
|
||||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"id": "map",
|
||||
"version": "0.1.0",
|
||||
"title": "Map Page",
|
||||
"description": "Full-map NODE.DC page with fixed system actions, Inspector, Toolbar and typed spatial data slots.",
|
||||
"category": "map",
|
||||
"page": {
|
||||
"id": "map",
|
||||
"title": "Map",
|
||||
"path": "/",
|
||||
"navigationLabel": "Map"
|
||||
},
|
||||
"features": [
|
||||
{ "id": "inspector", "label": "Inspector", "description": "Canonical draggable Glass Inspector for map settings and selected entities.", "defaultVisible": true, "required": true },
|
||||
{ "id": "toolbar", "label": "Toolbar", "description": "Canonical positional Toolbar with template-owned actions.", "defaultVisible": true },
|
||||
{ "id": "assistant", "label": "Assistant", "description": "Floating NODE.DC assistant entry point and overlay slot.", "defaultVisible": false }
|
||||
],
|
||||
"slots": [
|
||||
{ "id": "points", "kind": "entity-stream", "label": "Points", "description": "Live point entities with position and semantic state.", "required": false, "multiple": true },
|
||||
{ "id": "traces", "kind": "trace-stream", "label": "Traces", "description": "Time-ordered entity traces.", "required": false, "multiple": true },
|
||||
{ "id": "routes", "kind": "route-stream", "label": "Routes", "description": "Planned or computed routes.", "required": false, "multiple": true },
|
||||
{ "id": "zones", "kind": "zone-stream", "label": "Zones", "description": "Polygons, geofences and operational areas.", "required": false, "multiple": true },
|
||||
{ "id": "selection", "kind": "selection", "label": "Selection", "description": "Selected map entity and contextual state.", "required": false, "multiple": false },
|
||||
{ "id": "commands", "kind": "command", "label": "Commands", "description": "Capability-backed actions for selected entities.", "required": false, "multiple": true },
|
||||
{ "id": "assistant", "kind": "assistant", "label": "Assistant context", "description": "Context exposed to the assistant overlay.", "required": false, "multiple": false }
|
||||
],
|
||||
"actions": [
|
||||
{ "id": "open-inspector", "label": "Inspector", "command": "map.inspector.open", "feature": "inspector" },
|
||||
{ "id": "toggle-toolbar", "label": "Toolbar", "command": "map.toolbar.toggle", "feature": "toolbar" },
|
||||
{ "id": "open-assistant", "label": "Assistant", "command": "assistant.open", "feature": "assistant" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -23,12 +23,18 @@
|
|||
"name": "@nodedc/ui-dom",
|
||||
"path": "packages/ui-dom",
|
||||
"role": "DOM controllers for non-React applications"
|
||||
},
|
||||
{
|
||||
"name": "@nodedc/page-patterns",
|
||||
"path": "packages/page-patterns",
|
||||
"role": "runtime-neutral page template contracts and canonical template manifests"
|
||||
}
|
||||
],
|
||||
"sourceRegistry": "sources.json",
|
||||
"componentRegistry": "components.json",
|
||||
"candidateRegistry": "candidates.json",
|
||||
"iconRegistry": "icons.json",
|
||||
"pageRegistry": "pages.json",
|
||||
"documentation": {
|
||||
"architecture": "../docs/ARCHITECTURE.md",
|
||||
"components": "../docs/COMPONENTS.md",
|
||||
|
|
@ -39,8 +45,10 @@
|
|||
"adoption": "../docs/ADOPTION.md",
|
||||
"candidates": "../docs/CANDIDATES.md",
|
||||
"applicationTemplate": "../docs/APPLICATION_TEMPLATE.md",
|
||||
"moduleStudio": "../docs/MODULE_STUDIO.md",
|
||||
"icons": "../docs/ICONS.md",
|
||||
"consumption": "../docs/CONSUMPTION.md"
|
||||
},
|
||||
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
|
||||
"operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://nodedc.ru/schemas/application-manifest-v0.1.schema.json",
|
||||
"title": "NODE.DC Application Manifest v0.1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "id", "status", "version", "metadata", "designProfile", "pages", "favicon", "timestamps"],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "0.1.0" },
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"status": { "const": "draft" },
|
||||
"version": { "type": "string", "pattern": "^0\\.[0-9]+\\.[0-9]+$" },
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "slug", "description"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 },
|
||||
"description": { "type": "string", "maxLength": 500 }
|
||||
}
|
||||
},
|
||||
"designProfile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "theme"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string", "minLength": 1 },
|
||||
"theme": { "enum": ["dark", "light"] }
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "path", "template", "navigation", "features"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"path": { "type": "string", "minLength": 1 },
|
||||
"template": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["visible", "label", "order"],
|
||||
"properties": {
|
||||
"visible": { "type": "boolean" },
|
||||
"label": { "type": "string" },
|
||||
"order": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"favicon": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source"],
|
||||
"properties": {
|
||||
"source": { "const": "design-profile" }
|
||||
}
|
||||
},
|
||||
"timestamps": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["createdAt", "updatedAt"],
|
||||
"properties": {
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"updatedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ const sources = await parse("registry/sources.json");
|
|||
const components = await parse("registry/components.json");
|
||||
const candidates = await parse("registry/candidates.json");
|
||||
const icons = await parse("registry/icons.json");
|
||||
const pages = await parse("registry/pages.json");
|
||||
|
||||
const failures = [];
|
||||
const requireValue = (condition, message) => {
|
||||
|
|
@ -23,6 +24,8 @@ requireValue(Array.isArray(sources.sources) && sources.sources.length >= 6, "sou
|
|||
requireValue(Array.isArray(components.components) && components.components.length >= 10, "component registry is incomplete");
|
||||
requireValue(Array.isArray(candidates.candidates) && candidates.candidates.length >= 10, "candidate registry is incomplete");
|
||||
requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon registry is incomplete");
|
||||
requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must be 0.1.0");
|
||||
requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template");
|
||||
|
||||
const ids = components.components.map((component) => component.id);
|
||||
requireValue(new Set(ids).size === ids.length, "component ids must be unique");
|
||||
|
|
@ -32,6 +35,16 @@ requireValue(candidateIds.every((id) => !ids.includes(id)), "a component cannot
|
|||
const iconNames = icons.groups.flatMap((group) => group.names ?? []);
|
||||
requireValue(iconNames.length >= 35, "icon registry must contain the audited baseline set");
|
||||
requireValue(new Set(iconNames).size === iconNames.length, "icon names must be unique across groups");
|
||||
const pageTemplateKeys = pages.templates.map((template) => `${template.id}@${template.version}`);
|
||||
requireValue(new Set(pageTemplateKeys).size === pageTemplateKeys.length, "page template id/version pairs must be unique");
|
||||
|
||||
for (const template of pages.templates) {
|
||||
requireValue(template.schemaVersion === "0.1.0", `unsupported page template schema: ${template.id ?? "unknown"}`);
|
||||
requireValue(template.id && template.version && template.title && template.page, `page template entry is incomplete: ${template.id ?? "unknown"}`);
|
||||
requireValue(Array.isArray(template.features), `page template features are missing: ${template.id ?? "unknown"}`);
|
||||
requireValue(Array.isArray(template.slots), `page template slots are missing: ${template.id ?? "unknown"}`);
|
||||
requireValue(Array.isArray(template.actions), `page template actions are missing: ${template.id ?? "unknown"}`);
|
||||
}
|
||||
|
||||
for (const component of components.components) {
|
||||
requireValue(component.id && component.status && component.summary, `component entry is incomplete: ${component.id ?? "unknown"}`);
|
||||
|
|
@ -58,4 +71,4 @@ if (failures.length > 0) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Registry valid: ${components.components.length} components, ${iconNames.length} icons, ${candidates.candidates.length} candidates, ${sources.sources.length} audited sources.`);
|
||||
console.log(`Registry valid: ${components.components.length} components, ${iconNames.length} icons, ${pages.templates.length} page templates, ${candidates.candidates.length} candidates, ${sources.sources.length} audited sources.`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, extname, join, normalize } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -9,10 +10,20 @@ const root = fileURLToPath(new URL("..", import.meta.url));
|
|||
const distDir = join(root, "apps", "catalog", "dist");
|
||||
const runtimeDir = join(root, "runtime-data");
|
||||
const uploadDir = join(runtimeDir, "uploads");
|
||||
const applicationsDir = join(runtimeDir, "applications");
|
||||
const designProfilesDir = join(runtimeDir, "design-profiles");
|
||||
const layoutPath = join(runtimeDir, "layout.json");
|
||||
const pageRegistryPath = join(root, "registry", "pages.json");
|
||||
const port = Number(process.env.PORT || 3333);
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await mkdir(applicationsDir, { recursive: true });
|
||||
await mkdir(designProfilesDir, { recursive: true });
|
||||
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
|
||||
|
||||
function findPageTemplate(id, version) {
|
||||
return pageRegistry.templates.find((template) => template.id === id && (!version || template.version === version));
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
|
|
@ -37,6 +48,187 @@ function json(response, statusCode, value) {
|
|||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function applicationError(code, statusCode = 400) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function applicationPath(id) {
|
||||
const safeId = String(id || "");
|
||||
if (!/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_application_id");
|
||||
return join(applicationsDir, `${safeId}.json`);
|
||||
}
|
||||
|
||||
function designProfilePath(id) {
|
||||
const safeId = String(id || "");
|
||||
if (safeId !== "default" && !/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_design_profile_id");
|
||||
return join(designProfilesDir, `${safeId}.json`);
|
||||
}
|
||||
|
||||
function validateDesignProfile(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_design_profile");
|
||||
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_design_profile_schema");
|
||||
if (value.id !== "default" && !/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_design_profile_id");
|
||||
const name = String(value.name || "").trim();
|
||||
if (!name || name.length > 120) throw applicationError("invalid_design_profile_name");
|
||||
if (!value.layout || typeof value.layout !== "object" || Array.isArray(value.layout)) throw applicationError("invalid_design_profile_layout");
|
||||
return { ...value, name };
|
||||
}
|
||||
|
||||
async function ensureDefaultDesignProfile() {
|
||||
try {
|
||||
await readFile(designProfilePath("default"), "utf8");
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
let layout = {};
|
||||
try { layout = JSON.parse(await readFile(layoutPath, "utf8")); } catch (layoutError) { if (layoutError?.code !== "ENOENT") throw layoutError; }
|
||||
const now = new Date().toISOString();
|
||||
await writeFile(designProfilePath("default"), `${JSON.stringify({
|
||||
schemaVersion: "0.1.0",
|
||||
id: "default",
|
||||
name: "NODE.DC Default",
|
||||
version: "0.6.0",
|
||||
status: "draft",
|
||||
layout,
|
||||
timestamps: { createdAt: now, updatedAt: now },
|
||||
}, null, 2)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDefaultDesignProfile();
|
||||
|
||||
async function writeDesignProfile(profile) {
|
||||
const targetPath = designProfilePath(profile.id);
|
||||
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
function designProfileSummary(profile) {
|
||||
return { id: profile.id, name: profile.name, version: profile.version, theme: profile.layout?.theme === "light" ? "light" : "dark", updatedAt: profile.timestamps.updatedAt };
|
||||
}
|
||||
|
||||
function nextPatchVersion(version) {
|
||||
const match = String(version || "0.1.0").match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
return match ? `${match[1]}.${match[2]}.${Number(match[3]) + 1}` : "0.1.0";
|
||||
}
|
||||
|
||||
function validateApplicationManifest(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_application_manifest");
|
||||
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_application_schema");
|
||||
if (!/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_application_id");
|
||||
if (value.status !== "draft") throw applicationError("invalid_application_status");
|
||||
if (!/^0\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_application_version");
|
||||
if (!value.metadata || typeof value.metadata !== "object") throw applicationError("invalid_application_metadata");
|
||||
const name = String(value.metadata.name || "").trim();
|
||||
const slug = normalizeSlug(value.metadata.slug);
|
||||
if (!name || name.length > 120) throw applicationError("invalid_application_name");
|
||||
if (!slug) throw applicationError("invalid_application_slug");
|
||||
if (!value.designProfile || typeof value.designProfile !== "object") throw applicationError("invalid_design_profile");
|
||||
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
|
||||
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
|
||||
const pageIds = new Set();
|
||||
for (const page of value.pages) {
|
||||
if (!page || typeof page !== "object") throw applicationError("invalid_application_page");
|
||||
const pageId = String(page.id || "").trim();
|
||||
if (!/^[a-z0-9-]+$/.test(pageId) || pageIds.has(pageId)) throw applicationError("invalid_application_page_id");
|
||||
pageIds.add(pageId);
|
||||
if (!page.template || typeof page.template !== "object" || !String(page.template.id || "").trim()) {
|
||||
throw applicationError("invalid_application_page_template");
|
||||
}
|
||||
const template = findPageTemplate(String(page.template.id), String(page.template.version || ""));
|
||||
if (!template) throw applicationError("unknown_application_page_template");
|
||||
if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation");
|
||||
if (!page.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features");
|
||||
const allowedFeatures = new Set(template.features.map((feature) => feature.id));
|
||||
if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature");
|
||||
for (const feature of template.features) {
|
||||
if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled");
|
||||
}
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
metadata: {
|
||||
...value.metadata,
|
||||
name,
|
||||
slug,
|
||||
description: String(value.metadata.description || "").slice(0, 500),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationManifest(input = {}) {
|
||||
const templateId = String(input?.templateId || "").trim();
|
||||
const templateVersion = String(input?.templateVersion || "").trim() || undefined;
|
||||
const template = templateId ? findPageTemplate(templateId, templateVersion) : null;
|
||||
if (templateId && !template) throw applicationError("unknown_page_template");
|
||||
const now = new Date().toISOString();
|
||||
const name = String(input?.name || (template ? `NODE.DC ${template.title} Demo` : "Новый модуль")).trim() || "Новый модуль";
|
||||
const slug = normalizeSlug(input?.slug || name) || "nodedc-map-demo";
|
||||
return {
|
||||
schemaVersion: "0.1.0",
|
||||
id: randomUUID(),
|
||||
status: "draft",
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
name,
|
||||
slug,
|
||||
description: String(input?.description || "Первый Application Draft NDC Module Studio."),
|
||||
},
|
||||
designProfile: {
|
||||
id: "default",
|
||||
version: "0.6.0",
|
||||
theme: input?.theme === "light" ? "light" : "dark",
|
||||
},
|
||||
pages: template ? [{
|
||||
id: template.page.id,
|
||||
title: template.page.title,
|
||||
path: template.page.path,
|
||||
template: { id: template.id, version: template.version },
|
||||
navigation: { visible: true, label: template.page.navigationLabel, order: 0 },
|
||||
features: Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible])),
|
||||
}] : [],
|
||||
favicon: {
|
||||
source: "design-profile",
|
||||
},
|
||||
timestamps: {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeApplicationManifest(manifest) {
|
||||
const targetPath = applicationPath(manifest.id);
|
||||
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
function applicationSummary(manifest) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
|
@ -111,6 +303,131 @@ const server = createServer(async (request, response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/applications" && request.method === "GET") {
|
||||
const manifests = [];
|
||||
for (const entry of await readdir(applicationsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
try {
|
||||
const manifest = validateApplicationManifest(JSON.parse(await readFile(join(applicationsDir, entry.name), "utf8")));
|
||||
manifests.push(applicationSummary(manifest));
|
||||
} catch {
|
||||
// A damaged draft must not make the complete Studio project list unavailable.
|
||||
}
|
||||
}
|
||||
manifests.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
||||
json(response, 200, manifests);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/page-templates" && request.method === "GET") {
|
||||
json(response, 200, pageRegistry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/design-profiles" && request.method === "GET") {
|
||||
const profiles = [];
|
||||
for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
try { profiles.push(designProfileSummary(validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8"))))); } catch { /* skip damaged draft */ }
|
||||
}
|
||||
profiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
json(response, 200, profiles);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/design-profiles" && request.method === "POST") {
|
||||
const input = await readJsonBody(request);
|
||||
const now = new Date().toISOString();
|
||||
const profile = validateDesignProfile({
|
||||
schemaVersion: "0.1.0",
|
||||
id: randomUUID(),
|
||||
name: input?.name,
|
||||
version: "0.1.0",
|
||||
status: "draft",
|
||||
layout: input?.layout,
|
||||
timestamps: { createdAt: now, updatedAt: now },
|
||||
});
|
||||
await writeDesignProfile(profile);
|
||||
json(response, 201, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const designProfileMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})$/i);
|
||||
if (designProfileMatch && request.method === "GET") {
|
||||
try { json(response, 200, validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileMatch[1]), "utf8")))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
||||
return;
|
||||
}
|
||||
if (designProfileMatch && request.method === "PUT") {
|
||||
let current;
|
||||
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileMatch[1]), "utf8"))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
||||
const input = await readJsonBody(request);
|
||||
const next = validateDesignProfile({
|
||||
...current,
|
||||
name: input?.name || current.name,
|
||||
version: nextPatchVersion(current.version),
|
||||
layout: input?.layout,
|
||||
timestamps: { createdAt: current.timestamps.createdAt, updatedAt: new Date().toISOString() },
|
||||
});
|
||||
await writeDesignProfile(next);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/applications" && request.method === "POST") {
|
||||
const input = await readJsonBody(request);
|
||||
const manifest = validateApplicationManifest(createApplicationManifest(input));
|
||||
await writeApplicationManifest(manifest);
|
||||
json(response, 201, manifest);
|
||||
return;
|
||||
}
|
||||
|
||||
const applicationMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})$/i);
|
||||
if (applicationMatch && request.method === "GET") {
|
||||
try {
|
||||
const manifest = validateApplicationManifest(JSON.parse(await readFile(applicationPath(applicationMatch[1]), "utf8")));
|
||||
json(response, 200, manifest);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" });
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationMatch && request.method === "PUT") {
|
||||
const currentPath = applicationPath(applicationMatch[1]);
|
||||
let current;
|
||||
try {
|
||||
current = validateApplicationManifest(JSON.parse(await readFile(currentPath, "utf8")));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" });
|
||||
throw error;
|
||||
}
|
||||
const input = await readJsonBody(request);
|
||||
if (String(input?.id || "") !== applicationMatch[1]) throw applicationError("application_id_mismatch");
|
||||
const next = validateApplicationManifest({
|
||||
...input,
|
||||
timestamps: {
|
||||
createdAt: current.timestamps.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await writeApplicationManifest(next);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationMatch && request.method === "DELETE") {
|
||||
const currentPath = applicationPath(applicationMatch[1]);
|
||||
try { await stat(currentPath); } catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" }); throw error; }
|
||||
const deletedPath = join(runtimeDir, "deleted-applications");
|
||||
await mkdir(deletedPath, { recursive: true });
|
||||
await rename(currentPath, join(deletedPath, `${applicationMatch[1]}-${Date.now()}.json`));
|
||||
json(response, 200, { deleted: true, id: applicationMatch[1] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/layout" && request.method === "PUT") {
|
||||
const layout = await readJsonBody(request);
|
||||
const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 };
|
||||
|
|
@ -145,11 +462,13 @@ const server = createServer(async (request, response) => {
|
|||
await serveFile(request, response, join(distDir, "index.html"));
|
||||
}
|
||||
} catch (error) {
|
||||
json(response, error?.message === "payload_too_large" ? 413 : 500, { error: error?.message || "server_error" });
|
||||
const statusCode = error?.statusCode || (error?.message === "payload_too_large" ? 413 : 500);
|
||||
json(response, statusCode, { error: error?.message || "server_error" });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`NODE.DC Design Guideline: http://127.0.0.1:${port}`);
|
||||
console.log(`Layout store: ${layoutPath}`);
|
||||
console.log(`Application drafts: ${applicationsDir}`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue