Build Module Studio application composition

This commit is contained in:
DCCONSTRUCTIONS 2026-07-11 21:37:20 +03:00
parent d3e2859ca1
commit 50718a33a1
22 changed files with 2243 additions and 35 deletions

View File

@ -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",

View File

@ -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="Создать проект"

View File

@ -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;
}

View File

@ -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%;
}

View File

@ -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 при этом остаются независимыми и не уменьшаются.

69
docs/MODULE_STUDIO.md Normal file
View File

@ -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.

71
package-lock.json generated
View File

@ -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"
},

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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]));
}

View File

@ -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"]
}

View File

@ -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;

View File

@ -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"
},

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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",

38
registry/pages.json Normal file
View File

@ -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" }
]
}
]
}

View File

@ -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."
}

View File

@ -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" }
}
}
}
}

View File

@ -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.`);

View File

@ -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}`);
});