Build Module Studio application composition
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@nodedc/page-patterns",
|
||||
"version": "0.1.0",
|
||||
"description": "Runtime-neutral NODE.DC page template contracts and canonical template manifests.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type PageTemplateCategory = "map" | "assistant" | "analytics" | "monitoring" | "administration" | "workspace";
|
||||
|
||||
export type PageTemplateFeature = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultVisible: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PageTemplateSlotKind = "entity-stream" | "trace-stream" | "route-stream" | "zone-stream" | "selection" | "command" | "assistant";
|
||||
|
||||
export type PageTemplateSlot = {
|
||||
id: string;
|
||||
kind: PageTemplateSlotKind;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
};
|
||||
|
||||
export type PageTemplateAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
feature?: string;
|
||||
};
|
||||
|
||||
export type PageTemplateDefinition = {
|
||||
schemaVersion: "0.1.0";
|
||||
id: string;
|
||||
version: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PageTemplateCategory;
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
navigationLabel: string;
|
||||
};
|
||||
features: PageTemplateFeature[];
|
||||
slots: PageTemplateSlot[];
|
||||
actions: PageTemplateAction[];
|
||||
};
|
||||
|
||||
export const mapPageTemplate = {
|
||||
schemaVersion: "0.1.0",
|
||||
id: "map",
|
||||
version: "0.1.0",
|
||||
title: "Map Page",
|
||||
description: "Full-map NODE.DC page with fixed system actions, Inspector, Toolbar and typed spatial data slots.",
|
||||
category: "map",
|
||||
page: {
|
||||
id: "map",
|
||||
title: "Map",
|
||||
path: "/",
|
||||
navigationLabel: "Map",
|
||||
},
|
||||
features: [
|
||||
{ id: "inspector", label: "Inspector", description: "Canonical draggable Glass Inspector for map settings and selected entities.", defaultVisible: true, required: true },
|
||||
{ id: "toolbar", label: "Toolbar", description: "Canonical positional Toolbar with template-owned actions.", defaultVisible: true },
|
||||
{ id: "assistant", label: "Assistant", description: "Floating NODE.DC assistant entry point and overlay slot.", defaultVisible: false },
|
||||
],
|
||||
slots: [
|
||||
{ id: "points", kind: "entity-stream", label: "Points", description: "Live point entities with position and semantic state.", required: false, multiple: true },
|
||||
{ id: "traces", kind: "trace-stream", label: "Traces", description: "Time-ordered entity traces.", required: false, multiple: true },
|
||||
{ id: "routes", kind: "route-stream", label: "Routes", description: "Planned or computed routes.", required: false, multiple: true },
|
||||
{ id: "zones", kind: "zone-stream", label: "Zones", description: "Polygons, geofences and operational areas.", required: false, multiple: true },
|
||||
{ id: "selection", kind: "selection", label: "Selection", description: "Selected map entity and contextual state.", required: false, multiple: false },
|
||||
{ id: "commands", kind: "command", label: "Commands", description: "Capability-backed actions for selected entities.", required: false, multiple: true },
|
||||
{ id: "assistant", kind: "assistant", label: "Assistant context", description: "Context exposed to the assistant overlay.", required: false, multiple: false },
|
||||
],
|
||||
actions: [
|
||||
{ id: "open-inspector", label: "Inspector", command: "map.inspector.open", feature: "inspector" },
|
||||
{ id: "toggle-toolbar", label: "Toolbar", command: "map.toolbar.toggle", feature: "toolbar" },
|
||||
{ id: "open-assistant", label: "Assistant", command: "assistant.open", feature: "assistant" },
|
||||
],
|
||||
} as const satisfies PageTemplateDefinition;
|
||||
|
||||
export const pageTemplates = [mapPageTemplate] as const satisfies readonly PageTemplateDefinition[];
|
||||
|
||||
export function getPageTemplate(templateId: string, version?: string) {
|
||||
return pageTemplates.find((template) => template.id === templateId && (!version || template.version === version));
|
||||
}
|
||||
|
||||
export function createTemplateFeatures(template: PageTemplateDefinition) {
|
||||
return Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1411,9 +1411,6 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__close {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: grid;
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
@@ -1426,6 +1423,33 @@ textarea.nodedc-field__control {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__head-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__head-actions > .nodedc-icon-button {
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
flex-basis: 2.92rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__context-slot {
|
||||
display: grid;
|
||||
width: calc(100% + 2.2rem);
|
||||
margin-inline: -1.1rem;
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__context-slot .nodedc-select-anchor,
|
||||
.nodedc-admin-panel__context-slot .nodedc-select-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__close:hover {
|
||||
border-color: var(--nodedc-panel-action-border-hover);
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
@@ -1625,6 +1649,50 @@ textarea.nodedc-field__control {
|
||||
color: var(--nodedc-panel-active-icon-color);
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-nav {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-row .nodedc-admin-panel__nav-item {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.nodedc-admin-panel__sortable-row > .nodedc-drag-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.72rem;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.nodedc-drag-handle {
|
||||
display: inline-grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.nodedc-drag-handle:hover {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-drag-handle:active { cursor: grabbing; }
|
||||
.nodedc-drag-handle svg { fill: currentColor; }
|
||||
|
||||
.nodedc-admin-panel__footer {
|
||||
width: calc(100% + 2.2rem);
|
||||
margin-inline: -1.1rem;
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { SortableList } from "./DragDrop.js";
|
||||
|
||||
export interface AdminNavigationContext {
|
||||
id: string;
|
||||
@@ -16,12 +17,15 @@ export interface AdminNavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
contexts?: AdminNavigationContext[];
|
||||
headerActions?: ReactNode;
|
||||
contextSlot?: ReactNode;
|
||||
items: AdminNavigationItem[];
|
||||
activeId?: string;
|
||||
footer?: ReactNode;
|
||||
@@ -29,12 +33,15 @@ export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLEleme
|
||||
navigationLabel?: string;
|
||||
onClose: () => void;
|
||||
onItemChange: (id: string) => void;
|
||||
onItemsReorder?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function AdminNavigationPanel({
|
||||
eyebrow = "NODE.DC",
|
||||
title,
|
||||
contexts = [],
|
||||
headerActions,
|
||||
contextSlot,
|
||||
items,
|
||||
activeId,
|
||||
footer,
|
||||
@@ -42,21 +49,27 @@ export function AdminNavigationPanel({
|
||||
navigationLabel = "Разделы администрирования",
|
||||
onClose,
|
||||
onItemChange,
|
||||
onItemsReorder,
|
||||
className,
|
||||
...props
|
||||
}: AdminNavigationPanelProps) {
|
||||
return (
|
||||
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 ? "true" : undefined} {...props}>
|
||||
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 || contextSlot ? "true" : undefined} {...props}>
|
||||
<header className="nodedc-admin-panel__head">
|
||||
<div>
|
||||
<p>{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type NodedcDragData = Record<string, unknown>;
|
||||
|
||||
export interface DragRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DragDropResult {
|
||||
activeId: string;
|
||||
overId: string | null;
|
||||
activeData?: NodedcDragData;
|
||||
overData?: NodedcDragData;
|
||||
activeRect?: DragRect;
|
||||
overRect?: DragRect;
|
||||
}
|
||||
|
||||
const copyRect = (rect: DragRect | null | undefined): DragRect | undefined => rect ? ({
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}) : undefined;
|
||||
|
||||
export function DragHandle({ label = "Перетащить", listeners, attributes, className }: {
|
||||
label?: string;
|
||||
listeners?: ReturnType<typeof useDraggable>["listeners"];
|
||||
attributes?: ReturnType<typeof useDraggable>["attributes"];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("nodedc-drag-handle", className)}
|
||||
aria-label={label}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="0.75" /><circle cx="8" cy="4" r="0.75" /><circle cx="12" cy="4" r="0.75" />
|
||||
<circle cx="4" cy="8" r="0.75" /><circle cx="8" cy="8" r="0.75" /><circle cx="12" cy="8" r="0.75" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DragDropRoot({ children, onDragEnd }: { children: ReactNode; onDragEnd: (result: DragDropResult) => void }) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
onDragEnd({
|
||||
activeId: String(event.active.id),
|
||||
overId: event.over ? String(event.over.id) : null,
|
||||
activeData: event.active.data.current as NodedcDragData | undefined,
|
||||
overData: event.over?.data.current as NodedcDragData | undefined,
|
||||
activeRect: copyRect(event.active.rect.current.translated),
|
||||
overRect: copyRect(event.over?.rect),
|
||||
});
|
||||
};
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>{children}</DndContext>;
|
||||
}
|
||||
|
||||
export function DraggableItem({ id, data, className, children }: {
|
||||
id: string;
|
||||
data?: NodedcDragData;
|
||||
className?: string;
|
||||
children: (state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, data });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isDragging && "is-dragging")}
|
||||
style={{ transform: CSS.Translate.toString(transform), opacity: isDragging ? 0.65 : undefined }}
|
||||
>
|
||||
{children({ handle: <DragHandle listeners={listeners} attributes={attributes} />, isDragging })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropZone({ id, data, className, children }: { id: string; data?: NodedcDragData; className?: string; children: ReactNode }) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id, data });
|
||||
return <div ref={setNodeRef} className={cn(className, isOver && "is-drag-over")}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SortableScope({ ids, children }: { ids: string[]; children: ReactNode }) {
|
||||
return <SortableContext items={ids} strategy={verticalListSortingStrategy}>{children}</SortableContext>;
|
||||
}
|
||||
|
||||
export function SortableItem({ id, data, className, children }: {
|
||||
id: string;
|
||||
data?: NodedcDragData;
|
||||
className?: string;
|
||||
children: (state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, data });
|
||||
const verticalTransform = transform ? { ...transform, x: 0 } : null;
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isDragging && "is-dragging")}
|
||||
style={{ transform: CSS.Transform.toString(verticalTransform), transition, opacity: isDragging ? 0.65 : undefined }}
|
||||
>
|
||||
{children({ handle: <DragHandle listeners={listeners} attributes={attributes} />, isDragging })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableList<T>({ items, getId, className, onReorder, children }: {
|
||||
items: T[];
|
||||
getId: (item: T) => string;
|
||||
className?: string;
|
||||
onReorder: (items: T[]) => void;
|
||||
children: (item: T, state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const ids = items.map(getId);
|
||||
return (
|
||||
<DragDropRoot onDragEnd={({ activeId, overId }) => {
|
||||
if (!overId || activeId === overId) return;
|
||||
const from = ids.indexOf(activeId);
|
||||
const to = ids.indexOf(overId);
|
||||
if (from >= 0 && to >= 0) onReorder(arrayMove(items, from, to));
|
||||
}}>
|
||||
<SortableScope ids={ids}>
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<SortableItem key={getId(item)} id={getId(item)}>
|
||||
{(state) => children(item, state)}
|
||||
</SortableItem>
|
||||
))}
|
||||
</div>
|
||||
</SortableScope>
|
||||
</DragDropRoot>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from "./Button.js";
|
||||
export * from "./Checker.js";
|
||||
export * from "./ConfirmationModal.js";
|
||||
export * from "./Dropdown.js";
|
||||
export * from "./DragDrop.js";
|
||||
export * from "./Field.js";
|
||||
export * from "./Glass.js";
|
||||
export * from "./Inspector.js";
|
||||
|
||||
Reference in New Issue
Block a user