Persist catalog layout and add inspector toolbar

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 23:28:10 +03:00
parent 8ca12f2676
commit 406e692a5e
14 changed files with 715 additions and 268 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ dist/
.vite/
coverage/
*.tsbuildinfo
runtime-data/

View File

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

View File

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

View File

@ -14,6 +14,8 @@
Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки.
Постоянный theme switch передаётся через `ApplicationPanel.headerTools` и располагается в той же action-зоне. Если приложение использует Engine/BIM toolbar, он подключается отдельно к общему workspace-state и может располагаться слева, справа или снизу.
## Состояния desktop
| Состояние | Навигация | Content window | Stage |

View File

@ -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
В дизайн-системе живут элементы, значение которых не зависит от предметной области:

View File

@ -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 от повторного запуска. До завершения операции закрытие можно заблокировать.

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

140
server/catalog-server.mjs Normal file
View File

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