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
+19
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"
}
}
+89
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]));
}
+15
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"]
}
+71 -3
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;
+3
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"
},
+41 -5
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>
<button type="button" className="nodedc-admin-panel__close" aria-label={closeLabel} onClick={onClose}>
<span className="nodedc-close-mark" aria-hidden="true" />
</button>
<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}
+160
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>
);
}
+1
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";