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
+1
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",
+733 -22
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]) => ({
id,
label: item.title,
icon: <Icon name={item.icon} />,
}))}
activeId={activeSection ?? undefined}
onItemChange={openSection}
headerActions={studioContext === "applications" ? (
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
) : undefined}
contextSlot={studioContext === "applications" ? (
<Select
className="catalog-module-switcher"
label="Текущий модуль"
value={activeApplicationId ?? applicationSummaries[0]?.id ?? ""}
options={applicationSummaries.map((application) => ({ value: application.id, label: application.name, description: `${application.version} · ${application.status}`, icon: <Icon name="apps" /> }))}
searchable
searchPlaceholder="Поиск модулей"
emptyLabel="Модули не созданы"
onChange={(id) => { void openApplicationDraft(id); }}
/>
) : studioContext === "visual" ? (
<Select
className="catalog-module-switcher"
label="Design Profile"
value={activeDesignProfileId}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}`, icon: <Icon name="settings" /> }))}
onChange={(id) => { void openDesignProfile(id); }}
/>
) : undefined}
items={studioContext === "applications"
? activeApplication ? [
{ id: "__root__", label: "Общие настройки", icon: <Icon name="settings" /> },
...activeApplication.pages.filter((page) => page.navigation.visible).map((page) => ({ id: page.id, label: page.navigation.label, icon: <Icon name="globe" />, sortable: true })),
] : []
: studioContext === "pages"
? pageTemplates.map((template) => ({
id: template.id,
label: template.title,
icon: <Icon name="globe" />,
}))
: (Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
id,
label: item.title,
icon: <Icon name={item.icon} />,
}))}
activeId={studioContext === "applications" ? activeApplicationPageId ?? (activeApplication ? "__root__" : undefined) : studioContext === "pages" ? activePageTemplateId ?? undefined : activeSection ?? undefined}
onItemChange={(id) => studioContext === "applications"
? activeApplication && workspace.openView(id === "__root__" ? applicationViewId(activeApplication.id) : applicationPageViewId(activeApplication.id, id))
: studioContext === "pages" ? openPageTemplate(id) : openSection(id)}
onItemsReorder={studioContext === "applications" ? setApplicationPageOrder : undefined}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.6.0</span>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name={studioContext === "applications" ? "apps" : studioContext === "pages" ? "globe" : "shield"} /></span>
<span>{studioContext === "applications" ? activeApplication ? `${activeApplication.metadata.name} · ${activeApplication.version}` : `${applicationSummaries.length} drafts` : studioContext === "pages" ? `${pageTemplates.length} templates` : "Design System 0.6.0"}</span>
</>
}
/>
@@ -1084,17 +1667,61 @@ export function CatalogApp() {
utilityActions={[{
label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
icon: "save",
onClick: () => { void saveEnvironmentLayout(); },
onClick: () => {
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
setDesignProfileName(currentProfile?.name ?? "");
setDesignProfileSaveMode("save");
setDesignProfileSaveOpen(true);
},
disabled: layoutSaveState === "saving" || layoutSaveState === "loading",
}]}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderSectionContent()}</div>
</ApplicationPanel>
) : activePageTemplate ? (
<ApplicationPanel
key={`${activePageTemplate.id}@${activePageTemplate.version}`}
eyebrow="PAGE LIBRARY / CANONICAL"
title={activePageTemplate.title}
description={`${activePageTemplate.category} · contract ${activePageTemplate.schemaVersion} · template ${activePageTemplate.version}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderPageTemplate(activePageTemplate)}</div>
</ApplicationPanel>
) : activeApplication ? (
<ApplicationPanel
key={activeApplication.id}
eyebrow={activeApplicationPage ? "APPLICATION / PAGE" : "APPLICATION / ROOT"}
title={activeApplicationPage?.title ?? activeApplication.metadata.name}
description={activeApplicationPage ? `${activeApplication.metadata.name} · ${activeApplicationPage.template.id}@${activeApplicationPage.template.version}` : `/${activeApplication.metadata.slug} · manifest ${activeApplication.schemaVersion}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
utilityActions={[{
label: applicationSaveState === "saving" ? "Draft сохраняется" : applicationSaveState === "saved" ? "Draft сохранён" : applicationSaveState === "error" ? "Повторить сохранение draft" : "Сохранить Application Draft",
icon: "save",
onClick: () => { void saveApplicationDraft(); },
disabled: applicationSaveState === "saving" || applicationSaveState === "loading",
}, {
label: "Удалить модуль",
icon: "trash",
onClick: () => setDeleteModuleOpen(true),
}]}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">
<div className="catalog-module-mode">
<SegmentedControl label="Режим модуля" value={applicationMode} items={[{ value: "edit", label: "Настройка" }, { value: "preview", label: "Предпросмотр" }]} onChange={setApplicationMode} />
</div>
{activeApplicationPage ? renderApplicationPage() : renderApplicationDraft()}
</div>
</ApplicationPanel>
) : null}
/>
{guidelineOpen ? (
{guidelineOpen && studioContext === "visual" ? (
<Toolbar<CatalogSection>
placement={toolbarPlacement}
background={toolbarBg}
@@ -1126,6 +1753,90 @@ export function CatalogApp() {
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
</Window>
<Window
open={createModuleOpen}
title="Создать модуль"
subtitle="APPLICATION / DRAFT"
size="sm"
onClose={() => setCreateModuleOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateModuleOpen(false)}>Отмена</Button>
<WindowFooterActions>
<Button
variant="primary"
shape="pill"
icon={<Icon name="plus" />}
disabled={!createModuleName.trim() || !createModuleSlug.trim() || applicationSaveState === "saving"}
onClick={() => { void createApplicationDraft(undefined, { name: createModuleName, slug: createModuleSlug, description: createModuleDescription }); }}
>Создать модуль</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<TextField label="Название модуля" hint="обязательно" value={createModuleName} onChange={(event) => {
const value = event.target.value;
setCreateModuleName(value);
setCreateModuleSlug((current) => current ? current : value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
}} />
<TextField label="Slug" hint="латиница, цифры и дефис" value={createModuleSlug} onChange={(event) => setCreateModuleSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, "-"))} />
<TextAreaField label="Описание" value={createModuleDescription} onChange={(event) => setCreateModuleDescription(event.target.value)} />
</div>
</Window>
<Window
open={designProfileSaveOpen}
title="Сохранить Design Profile"
subtitle="VISUAL LIBRARY / PRESET"
size="sm"
onClose={() => setDesignProfileSaveOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setDesignProfileSaveOpen(false)}>Отмена</Button>
<WindowFooterActions>
{designProfileSaveMode === "save" ? (
<>
<Button onClick={() => { void saveDesignProfile("save"); }}>Сохранить</Button>
<Button variant="primary" shape="pill" onClick={() => { setDesignProfileName(""); setDesignProfileSaveMode("save-as"); }}>Сохранить как новый</Button>
</>
) : (
<Button variant="primary" shape="pill" disabled={!designProfileName.trim()} onClick={() => { void saveDesignProfile("save-as"); }}>Сохранить как новый</Button>
)}
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} />
{designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : (
<div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Будет создана новая draft-версия текущего профиля.</small></span></div>
)}
</div>
</Window>
<ConfirmationModal
open={deleteModuleOpen}
title="Удалить модуль?"
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Studio. Опубликованные releases эта операция не затрагивает.</p></>}
confirmLabel="Удалить draft"
pendingLabel="Удаление…"
danger
onClose={() => setDeleteModuleOpen(false)}
onConfirm={deleteApplicationDraft}
/>
<ConfirmationModal
open={Boolean(pageRemovalId)}
title="Удалить страницу из модуля?"
description={<><strong>{applicationDraft?.pages.find((page) => page.id === pageRemovalId)?.title ?? "Страница"}</strong><p>Экземпляр страницы и его настройки будут удалены из текущей конфигурации. Исходный Page Template останется доступен в библиотеке.</p></>}
confirmLabel="Удалить страницу"
pendingLabel="Удаление…"
danger
onClose={() => setPageRemovalId(null)}
onConfirm={confirmPageRemoval}
/>
<Window
open={createModalOpen}
title="Создать проект"
+85
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;
}
+387
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%;
}