Persist catalog layout and add inspector toolbar
This commit is contained in:
parent
8ca12f2676
commit
406e692a5e
|
|
@ -5,3 +5,4 @@ dist/
|
|||
.vite/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
runtime-data/
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
Inspector,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
|
|
@ -31,14 +32,16 @@ import {
|
|||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
Toolbar,
|
||||
useApplicationWorkspace,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type IconName,
|
||||
type ShareAccessMember,
|
||||
type ToolbarPlacement,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "icons";
|
||||
type CatalogSection = "controls" | "media" | "modals" | "icons";
|
||||
|
||||
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
|
||||
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
|
||||
|
|
@ -52,20 +55,58 @@ type MaterialDraft = {
|
|||
panelOpacity: number;
|
||||
fieldHex: string;
|
||||
fieldOpacity: number;
|
||||
nestedHex: string;
|
||||
};
|
||||
|
||||
interface StoredLayout {
|
||||
theme?: NodedcTheme;
|
||||
accentHex?: string;
|
||||
materialByTheme?: Record<NodedcTheme, MaterialDraft>;
|
||||
environment?: {
|
||||
lightColor?: string;
|
||||
brightness?: number;
|
||||
glowDistance?: number;
|
||||
connectionType?: string;
|
||||
connectionColor?: string;
|
||||
usePortColors?: boolean;
|
||||
fillColor?: string;
|
||||
fillOpacity?: number;
|
||||
strokeColor?: string;
|
||||
strokeOpacity?: number;
|
||||
};
|
||||
media?: {
|
||||
source?: "file" | "url";
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
fileSrc?: string;
|
||||
visible?: boolean;
|
||||
};
|
||||
toolbar?: {
|
||||
placement?: ToolbarPlacement;
|
||||
background?: string;
|
||||
border?: string;
|
||||
outline?: string;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
lensCount?: number;
|
||||
autoHide?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
|
||||
dark: {
|
||||
panelHex: "#151517",
|
||||
panelOpacity: 100,
|
||||
fieldHex: "#2a2a2c",
|
||||
fieldOpacity: 100,
|
||||
nestedHex: "#0b0b0d",
|
||||
},
|
||||
light: {
|
||||
panelHex: "#ffffff",
|
||||
panelOpacity: 100,
|
||||
fieldHex: "#ffffff",
|
||||
fieldOpacity: 100,
|
||||
nestedHex: "#f4f4f4",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -84,38 +125,26 @@ const selectOptions = [
|
|||
] as const;
|
||||
|
||||
const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = {
|
||||
foundation: {
|
||||
eyebrow: "01 / FOUNDATION",
|
||||
title: "Основа",
|
||||
description: "Тема, нейтральные поверхности и неизменяемая геометрия приложения.",
|
||||
icon: "panel",
|
||||
},
|
||||
controls: {
|
||||
eyebrow: "02 / CONTROLS",
|
||||
eyebrow: "01 / CONTROLS",
|
||||
title: "Контролы",
|
||||
description: "Кнопки, поля, выбор, Environment Settings и сворачиваемые секции нового Engine.",
|
||||
description: "Кнопки, поля, выбор, оконные действия и вызываемый Inspector нового Engine.",
|
||||
icon: "sliders",
|
||||
},
|
||||
media: {
|
||||
eyebrow: "03 / MEDIA & SETTINGS",
|
||||
eyebrow: "02 / MEDIA & SETTINGS",
|
||||
title: "Медиа и настройки",
|
||||
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
|
||||
icon: "image",
|
||||
},
|
||||
windows: {
|
||||
eyebrow: "04 / WINDOWS",
|
||||
title: "Окна",
|
||||
description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.",
|
||||
icon: "apps",
|
||||
},
|
||||
modals: {
|
||||
eyebrow: "05 / MODALS",
|
||||
eyebrow: "03 / MODALS",
|
||||
title: "Модалки",
|
||||
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
|
||||
icon: "clipboard",
|
||||
},
|
||||
icons: {
|
||||
eyebrow: "06 / ICONS",
|
||||
eyebrow: "04 / ICONS",
|
||||
title: "Иконки",
|
||||
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
|
||||
icon: "grid",
|
||||
|
|
@ -234,11 +263,10 @@ export function CatalogApp() {
|
|||
dark: { ...materialDefaults.dark },
|
||||
light: { ...materialDefaults.light },
|
||||
}));
|
||||
const [environmentLayoutSaved, setEnvironmentLayoutSaved] = useState(false);
|
||||
const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||||
const workspace = useApplicationWorkspace<CatalogSection>();
|
||||
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
||||
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
|
||||
const [checker, setChecker] = useState(true);
|
||||
const [brightness, setBrightness] = useState(49);
|
||||
const [glowDistance, setGlowDistance] = useState(105);
|
||||
const [usePortColors, setUsePortColors] = useState(false);
|
||||
|
|
@ -249,8 +277,7 @@ export function CatalogApp() {
|
|||
const [fillOpacity, setFillOpacity] = useState(100);
|
||||
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
||||
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
||||
const [windowOpen, setWindowOpen] = useState(false);
|
||||
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
||||
|
|
@ -268,6 +295,15 @@ export function CatalogApp() {
|
|||
const [mediaError, setMediaError] = useState("");
|
||||
const [mediaVisible, setMediaVisible] = useState(true);
|
||||
const mediaObjectUrlRef = useRef<string | null>(null);
|
||||
const pendingMediaFileRef = useRef<File | null>(null);
|
||||
const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
|
||||
const [toolbarBg, setToolbarBg] = useState("#111115");
|
||||
const [toolbarBorder, setToolbarBorder] = useState("#111117");
|
||||
const [toolbarOutline, setToolbarOutline] = useState("#1c1c1c");
|
||||
const [toolbarMinSize, setToolbarMinSize] = useState(25);
|
||||
const [toolbarMaxSize, setToolbarMaxSize] = useState(87);
|
||||
const [toolbarLensCount, setToolbarLensCount] = useState(5);
|
||||
const [toolbarAutoHide, setToolbarAutoHide] = useState(false);
|
||||
const [shareEmail, setShareEmail] = useState("");
|
||||
const [shareRole, setShareRole] = useState<ShareRole>("editor");
|
||||
const [shareMessage, setShareMessage] = useState("");
|
||||
|
|
@ -297,7 +333,63 @@ export function CatalogApp() {
|
|||
fieldOpacity: material.fieldOpacity / 100,
|
||||
},
|
||||
});
|
||||
}, [accent, fieldMaterial, material.fieldOpacity, material.panelOpacity, panelMaterial, theme]);
|
||||
document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex);
|
||||
}, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch("/api/layout", { cache: "no-store" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error("layout_load_failed");
|
||||
return await response.json() as StoredLayout | null;
|
||||
})
|
||||
.then((stored) => {
|
||||
if (!active || !stored) return;
|
||||
if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme);
|
||||
if (stored.accentHex) setAccentHex(stored.accentHex);
|
||||
if (stored.materialByTheme?.dark && stored.materialByTheme?.light) setMaterialByTheme(stored.materialByTheme);
|
||||
const environment = stored.environment;
|
||||
if (environment) {
|
||||
if (environment.lightColor) setLightColor(environment.lightColor);
|
||||
if (typeof environment.brightness === "number") setBrightness(environment.brightness);
|
||||
if (typeof environment.glowDistance === "number") setGlowDistance(environment.glowDistance);
|
||||
if (environment.connectionType) setConnectionType(environment.connectionType);
|
||||
if (environment.connectionColor) setConnectionColor(environment.connectionColor);
|
||||
if (typeof environment.usePortColors === "boolean") setUsePortColors(environment.usePortColors);
|
||||
if (environment.fillColor) setFillColor(environment.fillColor);
|
||||
if (typeof environment.fillOpacity === "number") setFillOpacity(environment.fillOpacity);
|
||||
if (environment.strokeColor) setStrokeColor(environment.strokeColor);
|
||||
if (typeof environment.strokeOpacity === "number") setStrokeOpacity(environment.strokeOpacity);
|
||||
}
|
||||
const media = stored.media;
|
||||
if (media) {
|
||||
if (media.source === "file" || media.source === "url") setMediaSource(media.source);
|
||||
if (typeof media.url === "string") setMediaUrl(media.url);
|
||||
if (media.fileName) setMediaFileName(media.fileName);
|
||||
if (media.fileSrc) setFileMediaSrc(media.fileSrc);
|
||||
if (typeof media.visible === "boolean") setMediaVisible(media.visible);
|
||||
}
|
||||
const toolbar = stored.toolbar;
|
||||
if (toolbar) {
|
||||
if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement);
|
||||
if (toolbar.background) setToolbarBg(toolbar.background);
|
||||
if (toolbar.border) setToolbarBorder(toolbar.border);
|
||||
if (toolbar.outline) setToolbarOutline(toolbar.outline);
|
||||
if (typeof toolbar.minSize === "number") setToolbarMinSize(toolbar.minSize);
|
||||
if (typeof toolbar.maxSize === "number") setToolbarMaxSize(toolbar.maxSize);
|
||||
if (typeof toolbar.lensCount === "number") setToolbarLensCount(toolbar.lensCount);
|
||||
if (typeof toolbar.autoHide === "boolean") setToolbarAutoHide(toolbar.autoHide);
|
||||
}
|
||||
setLayoutSaveState("saved");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLayoutSaveState("error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLayoutSaveState((current) => current === "loading" ? "idle" : current);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||
|
|
@ -313,6 +405,7 @@ export function CatalogApp() {
|
|||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
mediaObjectUrlRef.current = objectUrl;
|
||||
pendingMediaFileRef.current = file;
|
||||
setFileMediaSrc(objectUrl);
|
||||
setMediaFileName(file.name);
|
||||
setMediaSource("file");
|
||||
|
|
@ -344,11 +437,28 @@ export function CatalogApp() {
|
|||
}));
|
||||
};
|
||||
|
||||
const saveEnvironmentLayout = () => {
|
||||
const draft = {
|
||||
const saveEnvironmentLayout = async () => {
|
||||
setLayoutSaveState("saving");
|
||||
try {
|
||||
let persistedFileSrc = fileMediaSrc;
|
||||
const pendingMedia = pendingMediaFileRef.current;
|
||||
if (pendingMedia) {
|
||||
const uploadResponse = await fetch("/api/layout/media", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "video/mp4", "x-file-name": encodeURIComponent(pendingMedia.name) },
|
||||
body: pendingMedia,
|
||||
});
|
||||
if (!uploadResponse.ok) throw new Error("media_upload_failed");
|
||||
const uploaded = await uploadResponse.json() as { url: string; fileName: string };
|
||||
persistedFileSrc = uploaded.url;
|
||||
setFileMediaSrc(uploaded.url);
|
||||
setMediaFileName(uploaded.fileName);
|
||||
pendingMediaFileRef.current = null;
|
||||
}
|
||||
const draft: StoredLayout = {
|
||||
theme,
|
||||
accentHex,
|
||||
material,
|
||||
materialByTheme,
|
||||
environment: {
|
||||
lightColor,
|
||||
brightness,
|
||||
|
|
@ -361,13 +471,34 @@ export function CatalogApp() {
|
|||
strokeColor,
|
||||
strokeOpacity,
|
||||
},
|
||||
media: {
|
||||
source: mediaSource,
|
||||
url: mediaUrl,
|
||||
fileName: mediaFileName,
|
||||
fileSrc: persistedFileSrc,
|
||||
visible: mediaVisible,
|
||||
},
|
||||
toolbar: {
|
||||
placement: toolbarPlacement,
|
||||
background: toolbarBg,
|
||||
border: toolbarBorder,
|
||||
outline: toolbarOutline,
|
||||
minSize: toolbarMinSize,
|
||||
maxSize: toolbarMaxSize,
|
||||
lensCount: toolbarLensCount,
|
||||
autoHide: toolbarAutoHide,
|
||||
},
|
||||
};
|
||||
try {
|
||||
window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft));
|
||||
const response = await fetch("/api/layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
if (!response.ok) throw new Error("layout_save_failed");
|
||||
setLayoutSaveState("saved");
|
||||
} catch {
|
||||
// The preview remains useful in restricted browser contexts even without local persistence.
|
||||
setLayoutSaveState("error");
|
||||
}
|
||||
setEnvironmentLayoutSaved(true);
|
||||
};
|
||||
|
||||
const environmentSections = [
|
||||
|
|
@ -389,6 +520,42 @@ export function CatalogApp() {
|
|||
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
|
||||
</ControlRow>
|
||||
<RangeControl label="Прозрачность полей" value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
|
||||
<ControlRow label="Вложенная область">
|
||||
<ColorField label="Цвет вложенной области" value={material.nestedHex} onChange={(nestedHex) => updateMaterial({ nestedHex })} />
|
||||
</ControlRow>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "toolbar-settings",
|
||||
label: "Рабочее поле — Toolbar",
|
||||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Позиция">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Позиция Toolbar"
|
||||
value={toolbarPlacement}
|
||||
options={[
|
||||
{ value: "left", label: "Слева" },
|
||||
{ value: "right", label: "Справа" },
|
||||
{ value: "bottom", label: "Снизу" },
|
||||
]}
|
||||
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Фон"><ColorField value={toolbarBg} onChange={setToolbarBg} /></ControlRow>
|
||||
<ControlRow label="Border"><ColorField value={toolbarBorder} onChange={setToolbarBorder} /></ControlRow>
|
||||
<ControlRow label="Outline"><ColorField value={toolbarOutline} onChange={setToolbarOutline} /></ControlRow>
|
||||
<RangeControl label="Минимальный размер" value={toolbarMinSize} min={18} max={42} formatValue={(value) => `${value}px`} onChange={(value) => {
|
||||
const next = Math.round(value);
|
||||
setToolbarMinSize(next);
|
||||
setToolbarMaxSize((current) => Math.max(current, next));
|
||||
}} />
|
||||
<RangeControl label="Максимальный размер" value={toolbarMaxSize} min={28} max={88} formatValue={(value) => `${value}px`} onChange={(value) => setToolbarMaxSize(Math.max(toolbarMinSize, Math.round(value)))} />
|
||||
<RangeControl label="Линза" value={toolbarLensCount} min={1} max={13} step={2} formatValue={(value) => `${value} иконок`} onChange={(value) => setToolbarLensCount(Math.round(value) % 2 === 0 ? Math.round(value) + 1 : Math.round(value))} />
|
||||
<Checker checked={toolbarAutoHide} label="Автоскрытие" onChange={setToolbarAutoHide} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
@ -446,36 +613,21 @@ export function CatalogApp() {
|
|||
|
||||
const renderSectionContent = () => {
|
||||
switch (activeSection) {
|
||||
case "foundation":
|
||||
return (
|
||||
<div className="catalog-grid catalog-grid--foundation">
|
||||
<Preview title="Тема приложения" note={theme === "dark" ? "Launcher / dark" : "SEO / light"}>
|
||||
<div className="catalog-theme-row">
|
||||
<Button variant={theme === "dark" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
|
||||
<Button variant={theme === "light" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">Две базовые темы фиксированы по эталонам: чёрная — Launcher/Hub, белая — SEO. Стекло используется только в модальных окнах.</p>
|
||||
</Preview>
|
||||
<Preview title="Поверхности приложения" note="Launcher / SEO">
|
||||
<div className="catalog-surface-stack">
|
||||
<GlassSurface padding="md">Основная поверхность</GlassSurface>
|
||||
<GlassSurface tone="strong" padding="md">Контентное окно</GlassSurface>
|
||||
<GlassSurface tone="soft" padding="md">Вложенная область</GlassSurface>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Каркас Launcher" note="fixed header / stage / panels" className="catalog-preview--wide">
|
||||
<div className="catalog-shell-diagram" aria-label="Схема каркаса приложения">
|
||||
<span className="catalog-shell-diagram__header">Header</span>
|
||||
<span className="catalog-shell-diagram__nav">Navigation</span>
|
||||
<span className="catalog-shell-diagram__content">Content window</span>
|
||||
<span className="catalog-shell-diagram__stage">Stage</span>
|
||||
</div>
|
||||
</Preview>
|
||||
</div>
|
||||
);
|
||||
case "controls":
|
||||
return (
|
||||
<div className="catalog-grid">
|
||||
<Preview title="Inspector" note="modeless / draggable">
|
||||
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`">
|
||||
<div className="catalog-window-actions-demo">
|
||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Действия" note="общая геометрия">
|
||||
<div className="catalog-inline catalog-inline--wrap">
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
|
||||
|
|
@ -524,17 +676,6 @@ export function CatalogApp() {
|
|||
</Dropdown>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Environment controls" note="Engine / select + accordion" className="catalog-preview--wide">
|
||||
<div className="catalog-environment-demo">
|
||||
<GlassSurface tone="strong" radius="panel" className="catalog-environment-card">
|
||||
<div className="catalog-environment-title">
|
||||
<div><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div>
|
||||
</div>
|
||||
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
|
||||
</GlassSurface>
|
||||
<p className="catalog-preview__explanation">Environment controls — единый референс Engine: palette, range, checker, split-select и раскрывающиеся sections. Верхняя секция меняет цвет плашек и полей приложения; весь layout сохраняется общей кнопкой в шапке окна.</p>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
|
||||
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
|
||||
<div className="catalog-inline catalog-inline--wrap">
|
||||
|
|
@ -552,7 +693,7 @@ export function CatalogApp() {
|
|||
<SettingsCard
|
||||
eyebrow="CONTENT"
|
||||
title="Видео-окно"
|
||||
description="Окно демо-видео и подключённый медиа-файл. Хранилище и загрузка остаются в приложении; геометрия и переключение источника — в библиотеке."
|
||||
description="MP4 или URL становятся частью единого server-side layout вместе с темой, материалом и Toolbar."
|
||||
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
|
||||
>
|
||||
<MediaSourceField
|
||||
|
|
@ -564,7 +705,7 @@ export function CatalogApp() {
|
|||
previewKind="video"
|
||||
accept="video/mp4,.mp4"
|
||||
path="stage.videoSrc → runtime media source"
|
||||
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage."
|
||||
hint="Выбранный MP4 сразу показывается в stage и загружается на сервер общей кнопкой Save в шапке."
|
||||
error={mediaError}
|
||||
onSourceChange={setMediaSource}
|
||||
onUrlChange={(nextUrl) => {
|
||||
|
|
@ -574,33 +715,13 @@ export function CatalogApp() {
|
|||
onFileChange={handleStageMediaFile}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide">
|
||||
<Preview title="Серверное состояние" note="layout / media" className="catalog-preview--wide">
|
||||
<p className="catalog-preview__explanation">
|
||||
`MediaSourceField` владеет пикселями, доступностью и состояниями file/URL. Конкретный проект передаёт upload-обработчик, storage-path и результат валидации — поэтому компонент можно подключить к CMS, Launcher или будущему приложению без копирования его вёрстки.
|
||||
Save записывает один layout: обе темы, текущий accent, материалы, Environment controls, Toolbar и выбранный media-source. Очистка browser cache больше не удаляет конфигурацию.
|
||||
</p>
|
||||
</Preview>
|
||||
</div>
|
||||
);
|
||||
case "windows":
|
||||
return (
|
||||
<div className="catalog-grid catalog-grid--single">
|
||||
<Preview title="Управляемые окна" note="center / modeless end">
|
||||
<p className="catalog-preview__explanation">Обычное окно блокирует фон и удерживает фокус. Правый инспектор не затемняет, не блокирует stage и не превращается в modal.</p>
|
||||
<div className="catalog-inline catalog-inline--wrap">
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
|
||||
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setSidePanelOpen(true)}>Открыть боковую панель</Button>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`">
|
||||
<div className="catalog-window-actions-demo">
|
||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||
</div>
|
||||
</Preview>
|
||||
</div>
|
||||
);
|
||||
case "modals":
|
||||
return (
|
||||
<div className="catalog-modal-groups">
|
||||
|
|
@ -753,10 +874,19 @@ export function CatalogApp() {
|
|||
description={activeDefinition.description}
|
||||
expanded={panelExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
headerTools={
|
||||
<SegmentedControl
|
||||
label="Тема приложения"
|
||||
value={theme}
|
||||
items={[{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]}
|
||||
onChange={setTheme}
|
||||
/>
|
||||
}
|
||||
utilityActions={[{
|
||||
label: environmentLayoutSaved ? "Layout сохранён локально" : "Сохранить layout",
|
||||
label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
|
||||
icon: "save",
|
||||
onClick: saveEnvironmentLayout,
|
||||
onClick: () => { void saveEnvironmentLayout(); },
|
||||
disabled: layoutSaveState === "saving" || layoutSaveState === "loading",
|
||||
}]}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
|
|
@ -765,36 +895,36 @@ export function CatalogApp() {
|
|||
) : null}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={windowOpen}
|
||||
title="Настройки сервиса"
|
||||
subtitle="Launcher / CMS / SEO"
|
||||
onClose={() => setWindowOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button>
|
||||
<WindowFooterActions><Button variant="primary" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="catalog-form">
|
||||
<TextField label="Название" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
|
||||
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
|
||||
<Checker checked={checker} label="Сервис активен" onChange={setChecker} />
|
||||
</div>
|
||||
</Window>
|
||||
{guidelineOpen ? (
|
||||
<Toolbar<CatalogSection>
|
||||
placement={toolbarPlacement}
|
||||
background={toolbarBg}
|
||||
border={toolbarBorder}
|
||||
outline={toolbarOutline}
|
||||
accent={accentHex}
|
||||
minSize={toolbarMinSize}
|
||||
maxSize={toolbarMaxSize}
|
||||
lensCount={toolbarLensCount}
|
||||
autoHide={toolbarAutoHide}
|
||||
items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
|
||||
id,
|
||||
label: item.title,
|
||||
icon: item.icon,
|
||||
active: activeSection === id,
|
||||
onSelect: openSection,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Window
|
||||
open={sidePanelOpen}
|
||||
title="Боковая панель"
|
||||
subtitle="modeless / shell contract"
|
||||
open={inspectorOpen}
|
||||
title="Настройки окружения"
|
||||
subtitle="ENGINE / draggable inspector"
|
||||
placement="end"
|
||||
onClose={() => setSidePanelOpen(false)}
|
||||
draggable
|
||||
onClose={() => setInspectorOpen(false)}
|
||||
>
|
||||
<div className="catalog-form">
|
||||
<TextField label="Название панели" value="Параметры рабочей области" readOnly />
|
||||
<p className="catalog-preview__explanation">Боковая панель демонстрирует только layer-механику: она modeless, не затемняет stage и не забирает фокус. Environment controls живут в разделе «Контролы».</p>
|
||||
</div>
|
||||
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
|
|
|
|||
|
|
@ -110,10 +110,6 @@ textarea {
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-grid--foundation {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
}
|
||||
|
||||
.catalog-grid--single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -160,7 +156,6 @@ textarea {
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.catalog-theme-row,
|
||||
.catalog-inline,
|
||||
.catalog-window-actions-demo {
|
||||
display: flex;
|
||||
|
|
@ -173,37 +168,6 @@ textarea {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.catalog-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.catalog-swatch {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.4rem;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.55rem;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog-swatch span {
|
||||
height: 2.4rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--swatch);
|
||||
}
|
||||
|
||||
.catalog-swatch[data-active="true"] {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.catalog-surface-stack,
|
||||
.catalog-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
|
@ -313,14 +277,6 @@ textarea {
|
|||
flex-basis: 2rem;
|
||||
}
|
||||
|
||||
.catalog-surface-stack .nodedc-glass {
|
||||
display: flex;
|
||||
min-height: 4rem;
|
||||
align-items: center;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.catalog-engine-control-stack {
|
||||
display: grid;
|
||||
width: min(var(--nodedc-inspector-inner-width), 100%);
|
||||
|
|
@ -328,80 +284,6 @@ textarea {
|
|||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram {
|
||||
display: grid;
|
||||
min-height: 16rem;
|
||||
grid-template-columns: minmax(8rem, 0.28fr) minmax(15rem, 1fr) minmax(7rem, 0.22fr);
|
||||
grid-template-rows: 3.2rem 1fr;
|
||||
gap: 0.6rem;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--nodedc-canvas-soft);
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram__header {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram__nav {
|
||||
background: var(--nodedc-glass-control-hover) !important;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram__content {
|
||||
background: var(--nodedc-glass-control-bg) !important;
|
||||
}
|
||||
|
||||
.catalog-environment-demo {
|
||||
display: grid;
|
||||
min-height: 30rem;
|
||||
grid-template-columns: minmax(0, 24.375rem) minmax(14rem, 1fr);
|
||||
align-items: start;
|
||||
gap: clamp(1rem, 4vw, 4rem);
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-canvas-soft);
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.catalog-environment-card {
|
||||
width: min(24.375rem, 100%);
|
||||
padding: 1.125rem 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-environment-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
}
|
||||
|
||||
.catalog-environment-title > div {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.catalog-environment-title span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.catalog-environment-draft {
|
||||
margin: -0.25rem 0.875rem 0.75rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.catalog-icon-catalog {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
|
@ -489,8 +371,7 @@ textarea {
|
|||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.catalog-grid,
|
||||
.catalog-grid--foundation {
|
||||
.catalog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
@ -557,7 +438,6 @@ textarea {
|
|||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.catalog-swatches,
|
||||
.catalog-icon-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
|
@ -566,32 +446,12 @@ textarea {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram {
|
||||
grid-template-columns: 1fr 1.5fr;
|
||||
grid-template-rows: 3rem 7rem 7rem;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram__header {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.catalog-shell-diagram__stage {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.catalog-environment-demo {
|
||||
min-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-window-actions-demo .nodedc-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.catalog-swatches,
|
||||
.catalog-icon-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
|
||||
Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки.
|
||||
|
||||
Постоянный theme switch передаётся через `ApplicationPanel.headerTools` и располагается в той же action-зоне. Если приложение использует Engine/BIM toolbar, он подключается отдельно к общему workspace-state и может располагаться слева, справа или снизу.
|
||||
|
||||
## Состояния desktop
|
||||
|
||||
| Состояние | Навигация | Content window | Stage |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику.
|
||||
|
||||
Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Он не является backend-сервисом и не владеет данными приложений.
|
||||
Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Пакеты дизайн-системы не владеют данными приложений. Living catalog имеет собственный минимальный runtime-store только для сохранения единого демонстрационного layout и загруженного stage-media между браузерными сессиями.
|
||||
|
||||
## Слои
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ Core не зависит от React.
|
|||
|
||||
`@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
|
||||
|
||||
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
|
||||
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Toolbar` фиксирует Engine/BIM dock, magnification, placement и autohide. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
|
||||
|
||||
### DOM
|
||||
|
||||
|
|
@ -37,6 +37,8 @@ Core не зависит от React.
|
|||
|
||||
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
|
||||
|
||||
Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar и media source. Browser localStorage не является источником истины.
|
||||
|
||||
## Граница shared/domain
|
||||
|
||||
В дизайн-системе живут элементы, значение которых не зависит от предметной области:
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@ Window — единая механика открытия modal и правой
|
|||
|
||||
Содержание, сохранение и запросы принадлежат приложению.
|
||||
|
||||
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
||||
|
||||
## Toolbar
|
||||
|
||||
`Toolbar` — канонический dock из нового Engine с требованиями BIM Viewer. Он поддерживает позиции `left`, `right`, `bottom`, lens magnification и autohide. Контролируются фон, border, outline, минимальный/максимальный размер и количество иконок линзы.
|
||||
|
||||
Дефолты перенесены из Engine без переизобретения: `#111115`, `#111117`, `#1c1c1c`, `25 px`, `87 px`, `5`. Toolbar получает те же section actions, что и основная navigation, но не владеет route-state.
|
||||
|
||||
## ConfirmationModal
|
||||
|
||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react",
|
||||
"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",
|
||||
"validate:registry": "node scripts/validate-registry.mjs"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -1304,6 +1304,22 @@ textarea.nodedc-field__control {
|
|||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__tools .nodedc-segmented {
|
||||
min-height: 2.92rem;
|
||||
padding: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__tools .nodedc-segmented__item {
|
||||
min-height: 2.48rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__action {
|
||||
display: grid;
|
||||
width: 2.92rem;
|
||||
|
|
@ -1926,6 +1942,16 @@ textarea.nodedc-field__control {
|
|||
padding: 1.125rem 0.75rem 0.875rem 1.625rem;
|
||||
}
|
||||
|
||||
.nodedc-window[data-placement="end"][data-draggable="true"] .nodedc-window__head {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nodedc-window[data-placement="end"][data-draggable="true"] .nodedc-window__head:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.nodedc-window[data-placement="end"] .nodedc-window__body {
|
||||
padding: 0 0.75rem 1.125rem;
|
||||
}
|
||||
|
|
@ -2050,6 +2076,108 @@ textarea.nodedc-field__control {
|
|||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap {
|
||||
position: fixed;
|
||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="left"] {
|
||||
top: 50%;
|
||||
left: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="right"] {
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="bottom"] {
|
||||
bottom: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nodedc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.42rem;
|
||||
border: 1px solid var(--nodedc-toolbar-outline);
|
||||
border-radius: calc(var(--nodedc-toolbar-radius) + 0.42rem);
|
||||
background: color-mix(in srgb, var(--nodedc-toolbar-bg) 88%, transparent);
|
||||
box-shadow: 0 10px 28px color-mix(in srgb, var(--nodedc-toolbar-outline) 36%, transparent);
|
||||
backdrop-filter: blur(24px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.12);
|
||||
transition: opacity 180ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar,
|
||||
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="left"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
|
||||
transform: translateX(calc(-100% - 1rem));
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="right"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
|
||||
transform: translateX(calc(100% + 1rem));
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="bottom"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
|
||||
transform: translateY(calc(100% + 1rem));
|
||||
}
|
||||
|
||||
.nodedc-toolbar__hotzone {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone {
|
||||
inset: 5rem auto 5rem 0;
|
||||
width: 1.1rem;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar__hotzone {
|
||||
inset: 5rem 0 5rem auto;
|
||||
width: 1.1rem;
|
||||
}
|
||||
|
||||
.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar__hotzone {
|
||||
right: 5rem;
|
||||
bottom: 0;
|
||||
left: 5rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.nodedc-toolbar__button {
|
||||
display: grid;
|
||||
width: var(--nodedc-toolbar-item-size);
|
||||
height: var(--nodedc-toolbar-item-size);
|
||||
flex: 0 0 var(--nodedc-toolbar-item-size);
|
||||
place-items: center;
|
||||
border: 1px solid var(--nodedc-toolbar-border);
|
||||
border-radius: var(--nodedc-toolbar-radius);
|
||||
outline: 0;
|
||||
background: var(--nodedc-toolbar-bg);
|
||||
color: var(--nodedc-toolbar-accent);
|
||||
padding: 0;
|
||||
transition: width 120ms ease, height 120ms ease, flex-basis 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.nodedc-toolbar__button[data-active="true"] {
|
||||
border-color: var(--nodedc-toolbar-accent);
|
||||
background: var(--nodedc-toolbar-accent);
|
||||
color: rgb(var(--nodedc-on-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-window.nodedc-share-access-window {
|
||||
width: min(35rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export interface ApplicationPanelProps extends Omit<HTMLAttributes<HTMLElement>,
|
|||
expandLabel?: string;
|
||||
closeLabel?: string;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
/** Persistent custom controls rendered before utility icon actions. */
|
||||
headerTools?: ReactNode;
|
||||
/** Persistent utility actions rendered before expand/close in the panel header. */
|
||||
utilityActions?: ApplicationPanelUtilityAction[];
|
||||
onClose: () => void;
|
||||
|
|
@ -70,6 +72,7 @@ export function ApplicationPanel({
|
|||
expandLabel = "Развернуть окно",
|
||||
closeLabel = "Закрыть окно",
|
||||
onExpandedChange,
|
||||
headerTools,
|
||||
utilityActions = [],
|
||||
onClose,
|
||||
children,
|
||||
|
|
@ -85,6 +88,7 @@ export function ApplicationPanel({
|
|||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
<div className="nodedc-application-panel__actions">
|
||||
{headerTools ? <div className="nodedc-application-panel__tools">{headerTools}</div> : null}
|
||||
{utilityActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { useState, type CSSProperties } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon, type IconName } from "./Icon.js";
|
||||
|
||||
export type ToolbarPlacement = "left" | "right" | "bottom";
|
||||
|
||||
export interface ToolbarItem<T extends string = string> {
|
||||
id: T;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: (id: T) => void;
|
||||
}
|
||||
|
||||
export interface ToolbarProps<T extends string = string> {
|
||||
items: Array<ToolbarItem<T>>;
|
||||
placement?: ToolbarPlacement;
|
||||
background?: string;
|
||||
border?: string;
|
||||
outline?: string;
|
||||
accent?: string;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
lensCount?: number;
|
||||
autoHide?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toolbar<T extends string = string>({
|
||||
items,
|
||||
placement = "left",
|
||||
background = "#111115",
|
||||
border = "#111117",
|
||||
outline = "#1c1c1c",
|
||||
accent = "#ff2f92",
|
||||
minSize = 25,
|
||||
maxSize = 87,
|
||||
lensCount = 5,
|
||||
autoHide = false,
|
||||
label = "Панель инструментов",
|
||||
className,
|
||||
}: ToolbarProps<T>) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [visible, setVisible] = useState(!autoHide);
|
||||
const vertical = placement !== "bottom";
|
||||
const safeMin = Math.max(18, Math.min(42, minSize));
|
||||
const safeMax = Math.max(safeMin, Math.min(88, maxSize));
|
||||
const safeLens = Math.max(1, Math.min(13, lensCount % 2 === 0 ? lensCount + 1 : lensCount));
|
||||
const radius = Math.max(9, Math.round(safeMin * 0.42));
|
||||
|
||||
const style = {
|
||||
"--nodedc-toolbar-bg": background,
|
||||
"--nodedc-toolbar-border": border,
|
||||
"--nodedc-toolbar-outline": outline,
|
||||
"--nodedc-toolbar-accent": accent,
|
||||
"--nodedc-toolbar-min-size": `${safeMin}px`,
|
||||
"--nodedc-toolbar-max-size": `${safeMax}px`,
|
||||
"--nodedc-toolbar-radius": `${radius}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("nodedc-toolbar-wrap", className)}
|
||||
data-placement={placement}
|
||||
data-visible={visible ? "true" : undefined}
|
||||
data-autohide={autoHide ? "true" : undefined}
|
||||
style={style}
|
||||
onPointerEnter={() => setVisible(true)}
|
||||
onPointerLeave={() => {
|
||||
setHoveredIndex(null);
|
||||
if (autoHide) setVisible(false);
|
||||
}}
|
||||
>
|
||||
{autoHide ? <div className="nodedc-toolbar__hotzone" aria-hidden="true" onPointerEnter={() => setVisible(true)} /> : null}
|
||||
<div className="nodedc-toolbar" role="toolbar" aria-label={label}>
|
||||
{items.map((item, index) => {
|
||||
const distance = hoveredIndex === null ? Number.POSITIVE_INFINITY : Math.abs(index - hoveredIndex);
|
||||
const lensRadius = Math.floor(safeLens / 2);
|
||||
const influence = distance > lensRadius ? 0 : 1 - distance / Math.max(1, lensRadius + 1);
|
||||
const size = safeMin + (safeMax - safeMin) * influence;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="nodedc-toolbar__button"
|
||||
data-active={item.active ? "true" : undefined}
|
||||
aria-label={item.label}
|
||||
aria-pressed={item.active}
|
||||
disabled={item.disabled}
|
||||
style={{
|
||||
"--nodedc-toolbar-item-size": `${Math.round(size)}px`,
|
||||
"--nodedc-toolbar-item-shift": `${Math.round((size - safeMin) / (vertical ? 2 : 2))}px`,
|
||||
} as CSSProperties}
|
||||
onPointerEnter={() => setHoveredIndex(index)}
|
||||
onFocus={() => setHoveredIndex(index)}
|
||||
onBlur={() => setHoveredIndex(null)}
|
||||
onClick={() => item.onSelect(item.id)}
|
||||
>
|
||||
<Icon name={item.icon} size={Math.max(12, Math.min(18, Math.round(safeMin * 0.62)))} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,12 +2,14 @@ import {
|
|||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon } from "./Icon.js";
|
||||
|
||||
const focusableSelector = [
|
||||
"a[href]",
|
||||
|
|
@ -34,6 +36,7 @@ export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title
|
|||
closeOnEscape?: boolean;
|
||||
lockBodyScroll?: boolean;
|
||||
trapFocus?: boolean;
|
||||
draggable?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -50,13 +53,17 @@ export function Window({
|
|||
closeOnEscape = true,
|
||||
lockBodyScroll,
|
||||
trapFocus,
|
||||
draggable = false,
|
||||
onClose,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: WindowProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
|
||||
const shouldTrapFocus = trapFocus ?? placement === "center";
|
||||
|
||||
|
|
@ -104,6 +111,34 @@ export function Window({
|
|||
};
|
||||
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDragPosition(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggable || !open) return;
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
const dialog = dialogRef.current;
|
||||
if (!drag || !dialog || event.pointerId !== drag.pointerId) return;
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(window.innerWidth - bounds.width - 8, event.clientX - drag.offsetX));
|
||||
const top = Math.max(76, Math.min(window.innerHeight - bounds.height - 8, event.clientY - drag.offsetY));
|
||||
setDragPosition({ left, top });
|
||||
};
|
||||
const handlePointerUp = (event: globalThis.PointerEvent) => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
};
|
||||
}, [draggable, open]);
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
const allowBackdropClose = closeOnBackdrop ?? placement === "center";
|
||||
|
|
@ -119,20 +154,31 @@ export function Window({
|
|||
className={cn("nodedc-window nodedc-material-rim", className)}
|
||||
data-size={size === "md" ? undefined : size}
|
||||
data-placement={placement}
|
||||
data-draggable={draggable ? "true" : undefined}
|
||||
role="dialog"
|
||||
aria-modal={placement === "center" ? "true" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={subtitle ? descriptionId : undefined}
|
||||
tabIndex={-1}
|
||||
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
|
||||
{...props}
|
||||
>
|
||||
<header className="nodedc-window__head">
|
||||
<header
|
||||
className="nodedc-window__head"
|
||||
onPointerDown={(event) => {
|
||||
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return;
|
||||
const bounds = dialogRef.current?.getBoundingClientRect();
|
||||
if (!bounds) return;
|
||||
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top };
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="nodedc-window__titles">
|
||||
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
|
||||
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<span className="nodedc-close-mark" aria-hidden="true" />
|
||||
<Icon name="close" size={16} strokeWidth={1.6} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="nodedc-window__body">{children}</div>
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ export * from "./Select.js";
|
|||
export * from "./StatusBadge.js";
|
||||
export * from "./Settings.js";
|
||||
export * from "./SharingModals.js";
|
||||
export * from "./Toolbar.js";
|
||||
export * from "./Window.js";
|
||||
|
|
|
|||
|
|
@ -120,11 +120,12 @@
|
|||
"domExports": ["createModalController"],
|
||||
"domContract": ["nodedc-overlay", "nodedc-window"],
|
||||
"summary": "Canonical controlled window for modal and right-side panel presentations.",
|
||||
"behavior": ["portal to body", "focus entry", "modal-only focus trap", "focus restore", "Escape close", "backdrop close policy", "modal-only body scroll lock"],
|
||||
"behavior": ["portal to body", "focus entry", "modal-only focus trap", "focus restore", "Escape close", "backdrop close policy", "modal-only body scroll lock", "optional bounded dragging for modeless inspectors"],
|
||||
"rules": [
|
||||
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
||||
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
|
||||
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock."
|
||||
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
|
||||
"A draggable inspector moves by its header and remains clamped to the viewport."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -206,6 +207,21 @@
|
|||
"A layout-level save action belongs to ApplicationPanel.utilityActions and is never duplicated inside individual content cards."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "toolbar",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["Toolbar"],
|
||||
"domContract": ["nodedc-toolbar-wrap", "nodedc-toolbar", "nodedc-toolbar__button"],
|
||||
"summary": "Engine/BIM-derived magnifying application toolbar with left, right and bottom placements.",
|
||||
"variants": ["left", "right", "bottom"],
|
||||
"behavior": ["controlled active item", "lens magnification", "optional autohide hotzone", "theme-independent geometry"],
|
||||
"rules": [
|
||||
"Engine defaults are background #111115, border #111117, outline #1c1c1c, min 25 px, max 87 px and five lens items.",
|
||||
"Toolbar actions call application navigation; they do not duplicate route state.",
|
||||
"Placement and material are controlled values and belong to the persisted application layout."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "icon",
|
||||
"status": "baseline",
|
||||
|
|
@ -216,7 +232,7 @@
|
|||
"variants": ["14 px", "16 px", "18 px"],
|
||||
"rules": [
|
||||
"Use semantic names from registry/icons.json.",
|
||||
"The containing component owns surface, hit target and state; the icon owns only the glyph and is optically scaled to 78% inside controls.",
|
||||
"The containing component owns surface, hit target and state; the icon owns only the glyph and uses the canonical 16 px default without an extra CSS scale.",
|
||||
"Do not copy complete Lucide or Font Awesome catalogs into an application."
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { basename, extname, join, normalize } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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 layoutPath = join(runtimeDir, "layout.json");
|
||||
const port = Number(process.env.PORT || 3333);
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const mimeTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mp4": "video/mp4",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webm": "video/webm",
|
||||
};
|
||||
|
||||
function json(response, statusCode, value) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw new Error("payload_too_large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
function safeUploadName(name) {
|
||||
const extension = extname(name).toLowerCase() === ".mp4" ? ".mp4" : ".mp4";
|
||||
const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video";
|
||||
return `${Date.now()}-${stem}${extension}`;
|
||||
}
|
||||
|
||||
async function serveFile(request, response, filePath) {
|
||||
const info = await stat(filePath);
|
||||
const contentType = mimeTypes[extname(filePath).toLowerCase()] || "application/octet-stream";
|
||||
const range = request.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (range) {
|
||||
const start = range[1] ? Number(range[1]) : 0;
|
||||
const end = range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= info.size) {
|
||||
response.writeHead(416, { "content-range": `bytes */${info.size}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(206, {
|
||||
"content-type": contentType,
|
||||
"content-length": end - start + 1,
|
||||
"content-range": `bytes ${start}-${end}/${info.size}`,
|
||||
"accept-ranges": "bytes",
|
||||
});
|
||||
if (request.method === "HEAD") return response.end();
|
||||
createReadStream(filePath, { start, end }).pipe(response);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType,
|
||||
"content-length": info.size,
|
||||
"accept-ranges": "bytes",
|
||||
});
|
||||
if (request.method === "HEAD") return response.end();
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
||||
|
||||
if (url.pathname === "/api/layout" && request.method === "GET") {
|
||||
try {
|
||||
json(response, 200, JSON.parse(await readFile(layoutPath, "utf8")));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return json(response, 200, null);
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/layout" && request.method === "PUT") {
|
||||
const layout = await readJsonBody(request);
|
||||
const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 };
|
||||
const tempPath = `${layoutPath}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, layoutPath);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/layout/media" && request.method === "PUT") {
|
||||
const originalName = decodeURIComponent(String(request.headers["x-file-name"] || "stage-video.mp4"));
|
||||
const fileName = safeUploadName(originalName);
|
||||
const targetPath = join(uploadDir, fileName);
|
||||
await pipeline(request, createWriteStream(targetPath, { flags: "wx" }));
|
||||
json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/uploads/")) {
|
||||
const fileName = basename(normalize(url.pathname.slice("/uploads/".length)));
|
||||
await serveFile(request, response, join(uploadDir, fileName));
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\//, "");
|
||||
const candidate = join(distDir, normalize(relativePath));
|
||||
try {
|
||||
await serveFile(request, response, candidate);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
await serveFile(request, response, join(distDir, "index.html"));
|
||||
}
|
||||
} catch (error) {
|
||||
json(response, error?.message === "payload_too_large" ? 413 : 500, { 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}`);
|
||||
});
|
||||
Loading…
Reference in New Issue