Persist catalog layout and add inspector toolbar
This commit is contained in:
parent
8ca12f2676
commit
406e692a5e
|
|
@ -5,3 +5,4 @@ dist/
|
||||||
.vite/
|
.vite/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
runtime-data/
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
Inspector,
|
Inspector,
|
||||||
MediaSourceField,
|
MediaSourceField,
|
||||||
RangeControl,
|
RangeControl,
|
||||||
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
ShareAccessModal,
|
ShareAccessModal,
|
||||||
ShareLinkModal,
|
ShareLinkModal,
|
||||||
|
|
@ -31,14 +32,16 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
TextField,
|
TextField,
|
||||||
|
Toolbar,
|
||||||
useApplicationWorkspace,
|
useApplicationWorkspace,
|
||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
type IconName,
|
type IconName,
|
||||||
type ShareAccessMember,
|
type ShareAccessMember,
|
||||||
|
type ToolbarPlacement,
|
||||||
} from "@nodedc/ui-react";
|
} 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 }> = [
|
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
|
||||||
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
|
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
|
||||||
|
|
@ -52,20 +55,58 @@ type MaterialDraft = {
|
||||||
panelOpacity: number;
|
panelOpacity: number;
|
||||||
fieldHex: string;
|
fieldHex: string;
|
||||||
fieldOpacity: number;
|
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> = {
|
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
|
||||||
dark: {
|
dark: {
|
||||||
panelHex: "#151517",
|
panelHex: "#151517",
|
||||||
panelOpacity: 100,
|
panelOpacity: 100,
|
||||||
fieldHex: "#2a2a2c",
|
fieldHex: "#2a2a2c",
|
||||||
fieldOpacity: 100,
|
fieldOpacity: 100,
|
||||||
|
nestedHex: "#0b0b0d",
|
||||||
},
|
},
|
||||||
light: {
|
light: {
|
||||||
panelHex: "#ffffff",
|
panelHex: "#ffffff",
|
||||||
panelOpacity: 100,
|
panelOpacity: 100,
|
||||||
fieldHex: "#ffffff",
|
fieldHex: "#ffffff",
|
||||||
fieldOpacity: 100,
|
fieldOpacity: 100,
|
||||||
|
nestedHex: "#f4f4f4",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -84,38 +125,26 @@ const selectOptions = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = {
|
const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = {
|
||||||
foundation: {
|
|
||||||
eyebrow: "01 / FOUNDATION",
|
|
||||||
title: "Основа",
|
|
||||||
description: "Тема, нейтральные поверхности и неизменяемая геометрия приложения.",
|
|
||||||
icon: "panel",
|
|
||||||
},
|
|
||||||
controls: {
|
controls: {
|
||||||
eyebrow: "02 / CONTROLS",
|
eyebrow: "01 / CONTROLS",
|
||||||
title: "Контролы",
|
title: "Контролы",
|
||||||
description: "Кнопки, поля, выбор, Environment Settings и сворачиваемые секции нового Engine.",
|
description: "Кнопки, поля, выбор, оконные действия и вызываемый Inspector нового Engine.",
|
||||||
icon: "sliders",
|
icon: "sliders",
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
eyebrow: "03 / MEDIA & SETTINGS",
|
eyebrow: "02 / MEDIA & SETTINGS",
|
||||||
title: "Медиа и настройки",
|
title: "Медиа и настройки",
|
||||||
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
|
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
|
||||||
icon: "image",
|
icon: "image",
|
||||||
},
|
},
|
||||||
windows: {
|
|
||||||
eyebrow: "04 / WINDOWS",
|
|
||||||
title: "Окна",
|
|
||||||
description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.",
|
|
||||||
icon: "apps",
|
|
||||||
},
|
|
||||||
modals: {
|
modals: {
|
||||||
eyebrow: "05 / MODALS",
|
eyebrow: "03 / MODALS",
|
||||||
title: "Модалки",
|
title: "Модалки",
|
||||||
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
|
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
|
||||||
icon: "clipboard",
|
icon: "clipboard",
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
eyebrow: "06 / ICONS",
|
eyebrow: "04 / ICONS",
|
||||||
title: "Иконки",
|
title: "Иконки",
|
||||||
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
|
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
|
||||||
icon: "grid",
|
icon: "grid",
|
||||||
|
|
@ -234,11 +263,10 @@ export function CatalogApp() {
|
||||||
dark: { ...materialDefaults.dark },
|
dark: { ...materialDefaults.dark },
|
||||||
light: { ...materialDefaults.light },
|
light: { ...materialDefaults.light },
|
||||||
}));
|
}));
|
||||||
const [environmentLayoutSaved, setEnvironmentLayoutSaved] = useState(false);
|
const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||||||
const workspace = useApplicationWorkspace<CatalogSection>();
|
const workspace = useApplicationWorkspace<CatalogSection>();
|
||||||
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
|
||||||
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
|
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
|
||||||
const [checker, setChecker] = useState(true);
|
|
||||||
const [brightness, setBrightness] = useState(49);
|
const [brightness, setBrightness] = useState(49);
|
||||||
const [glowDistance, setGlowDistance] = useState(105);
|
const [glowDistance, setGlowDistance] = useState(105);
|
||||||
const [usePortColors, setUsePortColors] = useState(false);
|
const [usePortColors, setUsePortColors] = useState(false);
|
||||||
|
|
@ -249,8 +277,7 @@ export function CatalogApp() {
|
||||||
const [fillOpacity, setFillOpacity] = useState(100);
|
const [fillOpacity, setFillOpacity] = useState(100);
|
||||||
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
||||||
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
||||||
const [windowOpen, setWindowOpen] = useState(false);
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||||
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
||||||
|
|
@ -268,6 +295,15 @@ export function CatalogApp() {
|
||||||
const [mediaError, setMediaError] = useState("");
|
const [mediaError, setMediaError] = useState("");
|
||||||
const [mediaVisible, setMediaVisible] = useState(true);
|
const [mediaVisible, setMediaVisible] = useState(true);
|
||||||
const mediaObjectUrlRef = useRef<string | null>(null);
|
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 [shareEmail, setShareEmail] = useState("");
|
||||||
const [shareRole, setShareRole] = useState<ShareRole>("editor");
|
const [shareRole, setShareRole] = useState<ShareRole>("editor");
|
||||||
const [shareMessage, setShareMessage] = useState("");
|
const [shareMessage, setShareMessage] = useState("");
|
||||||
|
|
@ -297,7 +333,63 @@ export function CatalogApp() {
|
||||||
fieldOpacity: material.fieldOpacity / 100,
|
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(() => () => {
|
useEffect(() => () => {
|
||||||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||||
|
|
@ -313,6 +405,7 @@ export function CatalogApp() {
|
||||||
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
mediaObjectUrlRef.current = objectUrl;
|
mediaObjectUrlRef.current = objectUrl;
|
||||||
|
pendingMediaFileRef.current = file;
|
||||||
setFileMediaSrc(objectUrl);
|
setFileMediaSrc(objectUrl);
|
||||||
setMediaFileName(file.name);
|
setMediaFileName(file.name);
|
||||||
setMediaSource("file");
|
setMediaSource("file");
|
||||||
|
|
@ -344,11 +437,28 @@ export function CatalogApp() {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEnvironmentLayout = () => {
|
const saveEnvironmentLayout = async () => {
|
||||||
const draft = {
|
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,
|
theme,
|
||||||
accentHex,
|
accentHex,
|
||||||
material,
|
materialByTheme,
|
||||||
environment: {
|
environment: {
|
||||||
lightColor,
|
lightColor,
|
||||||
brightness,
|
brightness,
|
||||||
|
|
@ -361,13 +471,34 @@ export function CatalogApp() {
|
||||||
strokeColor,
|
strokeColor,
|
||||||
strokeOpacity,
|
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 {
|
const response = await fetch("/api/layout", {
|
||||||
window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft));
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(draft),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("layout_save_failed");
|
||||||
|
setLayoutSaveState("saved");
|
||||||
} catch {
|
} catch {
|
||||||
// The preview remains useful in restricted browser contexts even without local persistence.
|
setLayoutSaveState("error");
|
||||||
}
|
}
|
||||||
setEnvironmentLayoutSaved(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const environmentSections = [
|
const environmentSections = [
|
||||||
|
|
@ -389,6 +520,42 @@ export function CatalogApp() {
|
||||||
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
|
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
|
||||||
</ControlRow>
|
</ControlRow>
|
||||||
<RangeControl label="Прозрачность полей" value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
|
<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 = () => {
|
const renderSectionContent = () => {
|
||||||
switch (activeSection) {
|
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":
|
case "controls":
|
||||||
return (
|
return (
|
||||||
<div className="catalog-grid">
|
<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="общая геометрия">
|
<Preview title="Действия" note="общая геометрия">
|
||||||
<div className="catalog-inline catalog-inline--wrap">
|
<div className="catalog-inline catalog-inline--wrap">
|
||||||
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
|
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
|
||||||
|
|
@ -524,17 +676,6 @@ export function CatalogApp() {
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</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">
|
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
|
||||||
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
|
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
|
||||||
<div className="catalog-inline catalog-inline--wrap">
|
<div className="catalog-inline catalog-inline--wrap">
|
||||||
|
|
@ -552,7 +693,7 @@ export function CatalogApp() {
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
eyebrow="CONTENT"
|
eyebrow="CONTENT"
|
||||||
title="Видео-окно"
|
title="Видео-окно"
|
||||||
description="Окно демо-видео и подключённый медиа-файл. Хранилище и загрузка остаются в приложении; геометрия и переключение источника — в библиотеке."
|
description="MP4 или URL становятся частью единого server-side layout вместе с темой, материалом и Toolbar."
|
||||||
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
|
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
|
||||||
>
|
>
|
||||||
<MediaSourceField
|
<MediaSourceField
|
||||||
|
|
@ -564,7 +705,7 @@ export function CatalogApp() {
|
||||||
previewKind="video"
|
previewKind="video"
|
||||||
accept="video/mp4,.mp4"
|
accept="video/mp4,.mp4"
|
||||||
path="stage.videoSrc → runtime media source"
|
path="stage.videoSrc → runtime media source"
|
||||||
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage."
|
hint="Выбранный MP4 сразу показывается в stage и загружается на сервер общей кнопкой Save в шапке."
|
||||||
error={mediaError}
|
error={mediaError}
|
||||||
onSourceChange={setMediaSource}
|
onSourceChange={setMediaSource}
|
||||||
onUrlChange={(nextUrl) => {
|
onUrlChange={(nextUrl) => {
|
||||||
|
|
@ -574,33 +715,13 @@ export function CatalogApp() {
|
||||||
onFileChange={handleStageMediaFile}
|
onFileChange={handleStageMediaFile}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide">
|
<Preview title="Серверное состояние" note="layout / media" className="catalog-preview--wide">
|
||||||
<p className="catalog-preview__explanation">
|
<p className="catalog-preview__explanation">
|
||||||
`MediaSourceField` владеет пикселями, доступностью и состояниями file/URL. Конкретный проект передаёт upload-обработчик, storage-path и результат валидации — поэтому компонент можно подключить к CMS, Launcher или будущему приложению без копирования его вёрстки.
|
Save записывает один layout: обе темы, текущий accent, материалы, Environment controls, Toolbar и выбранный media-source. Очистка browser cache больше не удаляет конфигурацию.
|
||||||
</p>
|
</p>
|
||||||
</Preview>
|
</Preview>
|
||||||
</div>
|
</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":
|
case "modals":
|
||||||
return (
|
return (
|
||||||
<div className="catalog-modal-groups">
|
<div className="catalog-modal-groups">
|
||||||
|
|
@ -753,10 +874,19 @@ export function CatalogApp() {
|
||||||
description={activeDefinition.description}
|
description={activeDefinition.description}
|
||||||
expanded={panelExpanded}
|
expanded={panelExpanded}
|
||||||
onExpandedChange={workspace.setContentExpanded}
|
onExpandedChange={workspace.setContentExpanded}
|
||||||
|
headerTools={
|
||||||
|
<SegmentedControl
|
||||||
|
label="Тема приложения"
|
||||||
|
value={theme}
|
||||||
|
items={[{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]}
|
||||||
|
onChange={setTheme}
|
||||||
|
/>
|
||||||
|
}
|
||||||
utilityActions={[{
|
utilityActions={[{
|
||||||
label: environmentLayoutSaved ? "Layout сохранён локально" : "Сохранить layout",
|
label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
|
||||||
icon: "save",
|
icon: "save",
|
||||||
onClick: saveEnvironmentLayout,
|
onClick: () => { void saveEnvironmentLayout(); },
|
||||||
|
disabled: layoutSaveState === "saving" || layoutSaveState === "loading",
|
||||||
}]}
|
}]}
|
||||||
onClose={workspace.closeView}
|
onClose={workspace.closeView}
|
||||||
>
|
>
|
||||||
|
|
@ -765,36 +895,36 @@ export function CatalogApp() {
|
||||||
) : null}
|
) : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Window
|
{guidelineOpen ? (
|
||||||
open={windowOpen}
|
<Toolbar<CatalogSection>
|
||||||
title="Настройки сервиса"
|
placement={toolbarPlacement}
|
||||||
subtitle="Launcher / CMS / SEO"
|
background={toolbarBg}
|
||||||
onClose={() => setWindowOpen(false)}
|
border={toolbarBorder}
|
||||||
footer={
|
outline={toolbarOutline}
|
||||||
<>
|
accent={accentHex}
|
||||||
<Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button>
|
minSize={toolbarMinSize}
|
||||||
<WindowFooterActions><Button variant="primary" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
|
maxSize={toolbarMaxSize}
|
||||||
</>
|
lensCount={toolbarLensCount}
|
||||||
}
|
autoHide={toolbarAutoHide}
|
||||||
>
|
items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
|
||||||
<div className="catalog-form">
|
id,
|
||||||
<TextField label="Название" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
|
label: item.title,
|
||||||
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
|
icon: item.icon,
|
||||||
<Checker checked={checker} label="Сервис активен" onChange={setChecker} />
|
active: activeSection === id,
|
||||||
</div>
|
onSelect: openSection,
|
||||||
</Window>
|
}))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={sidePanelOpen}
|
open={inspectorOpen}
|
||||||
title="Боковая панель"
|
title="Настройки окружения"
|
||||||
subtitle="modeless / shell contract"
|
subtitle="ENGINE / draggable inspector"
|
||||||
placement="end"
|
placement="end"
|
||||||
onClose={() => setSidePanelOpen(false)}
|
draggable
|
||||||
|
onClose={() => setInspectorOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="catalog-form">
|
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
|
||||||
<TextField label="Название панели" value="Параметры рабочей области" readOnly />
|
|
||||||
<p className="catalog-preview__explanation">Боковая панель демонстрирует только layer-механику: она modeless, не затемняет stage и не забирает фокус. Environment controls живут в разделе «Контролы».</p>
|
|
||||||
</div>
|
|
||||||
</Window>
|
</Window>
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,6 @@ textarea {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-grid--foundation {
|
|
||||||
grid-template-columns: 1.1fr 0.9fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-grid--single {
|
.catalog-grid--single {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +156,6 @@ textarea {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-theme-row,
|
|
||||||
.catalog-inline,
|
.catalog-inline,
|
||||||
.catalog-window-actions-demo {
|
.catalog-window-actions-demo {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -173,37 +168,6 @@ textarea {
|
||||||
flex-wrap: wrap;
|
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 {
|
.catalog-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
@ -313,14 +277,6 @@ textarea {
|
||||||
flex-basis: 2rem;
|
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 {
|
.catalog-engine-control-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(var(--nodedc-inspector-inner-width), 100%);
|
width: min(var(--nodedc-inspector-inner-width), 100%);
|
||||||
|
|
@ -328,80 +284,6 @@ textarea {
|
||||||
gap: 0.75rem;
|
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 {
|
.catalog-icon-catalog {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -489,8 +371,7 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.catalog-grid,
|
.catalog-grid {
|
||||||
.catalog-grid--foundation {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,7 +438,6 @@ textarea {
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-swatches,
|
|
||||||
.catalog-icon-grid {
|
.catalog-icon-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -566,32 +446,12 @@ textarea {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.catalog-window-actions-demo .nodedc-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 430px) {
|
@media (max-width: 430px) {
|
||||||
.catalog-swatches,
|
|
||||||
.catalog-icon-grid {
|
.catalog-icon-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки.
|
Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки.
|
||||||
|
|
||||||
|
Постоянный theme switch передаётся через `ApplicationPanel.headerTools` и располагается в той же action-зоне. Если приложение использует Engine/BIM toolbar, он подключается отдельно к общему workspace-state и может располагаться слева, справа или снизу.
|
||||||
|
|
||||||
## Состояния desktop
|
## Состояния desktop
|
||||||
|
|
||||||
| Состояние | Навигация | Content window | Stage |
|
| Состояние | Навигация | Content window | Stage |
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику.
|
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-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
|
`@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
|
### DOM
|
||||||
|
|
||||||
|
|
@ -37,6 +37,8 @@ Core не зависит от React.
|
||||||
|
|
||||||
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
|
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
|
||||||
|
|
||||||
|
Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar и media source. Browser localStorage не является источником истины.
|
||||||
|
|
||||||
## Граница shared/domain
|
## Граница 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
|
||||||
|
|
||||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
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",
|
"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",
|
"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",
|
"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"
|
"validate:registry": "node scripts/validate-registry.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -1304,6 +1304,22 @@ textarea.nodedc-field__control {
|
||||||
gap: 0.45rem;
|
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 {
|
.nodedc-application-panel__action {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 2.92rem;
|
width: 2.92rem;
|
||||||
|
|
@ -1926,6 +1942,16 @@ textarea.nodedc-field__control {
|
||||||
padding: 1.125rem 0.75rem 0.875rem 1.625rem;
|
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 {
|
.nodedc-window[data-placement="end"] .nodedc-window__body {
|
||||||
padding: 0 0.75rem 1.125rem;
|
padding: 0 0.75rem 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
@ -2050,6 +2076,108 @@ textarea.nodedc-field__control {
|
||||||
gap: var(--nodedc-space-2);
|
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 {
|
.nodedc-window.nodedc-share-access-window {
|
||||||
width: min(35rem, calc(100vw - 2rem));
|
width: min(35rem, calc(100vw - 2rem));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export interface ApplicationPanelProps extends Omit<HTMLAttributes<HTMLElement>,
|
||||||
expandLabel?: string;
|
expandLabel?: string;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
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. */
|
/** Persistent utility actions rendered before expand/close in the panel header. */
|
||||||
utilityActions?: ApplicationPanelUtilityAction[];
|
utilityActions?: ApplicationPanelUtilityAction[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -70,6 +72,7 @@ export function ApplicationPanel({
|
||||||
expandLabel = "Развернуть окно",
|
expandLabel = "Развернуть окно",
|
||||||
closeLabel = "Закрыть окно",
|
closeLabel = "Закрыть окно",
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
|
headerTools,
|
||||||
utilityActions = [],
|
utilityActions = [],
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
|
@ -85,6 +88,7 @@ export function ApplicationPanel({
|
||||||
{description ? <p>{description}</p> : null}
|
{description ? <p>{description}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="nodedc-application-panel__actions">
|
<div className="nodedc-application-panel__actions">
|
||||||
|
{headerTools ? <div className="nodedc-application-panel__tools">{headerTools}</div> : null}
|
||||||
{utilityActions.map((action) => (
|
{utilityActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.label}
|
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,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
import { Icon } from "./Icon.js";
|
||||||
|
|
||||||
const focusableSelector = [
|
const focusableSelector = [
|
||||||
"a[href]",
|
"a[href]",
|
||||||
|
|
@ -34,6 +36,7 @@ export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title
|
||||||
closeOnEscape?: boolean;
|
closeOnEscape?: boolean;
|
||||||
lockBodyScroll?: boolean;
|
lockBodyScroll?: boolean;
|
||||||
trapFocus?: boolean;
|
trapFocus?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,13 +53,17 @@ export function Window({
|
||||||
closeOnEscape = true,
|
closeOnEscape = true,
|
||||||
lockBodyScroll,
|
lockBodyScroll,
|
||||||
trapFocus,
|
trapFocus,
|
||||||
|
draggable = false,
|
||||||
onClose,
|
onClose,
|
||||||
className,
|
className,
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: WindowProps) {
|
}: WindowProps) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
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 shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
|
||||||
const shouldTrapFocus = trapFocus ?? placement === "center";
|
const shouldTrapFocus = trapFocus ?? placement === "center";
|
||||||
|
|
||||||
|
|
@ -104,6 +111,34 @@ export function Window({
|
||||||
};
|
};
|
||||||
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
|
}, [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;
|
if (!open || typeof document === "undefined") return null;
|
||||||
|
|
||||||
const allowBackdropClose = closeOnBackdrop ?? placement === "center";
|
const allowBackdropClose = closeOnBackdrop ?? placement === "center";
|
||||||
|
|
@ -119,20 +154,31 @@ export function Window({
|
||||||
className={cn("nodedc-window nodedc-material-rim", className)}
|
className={cn("nodedc-window nodedc-material-rim", className)}
|
||||||
data-size={size === "md" ? undefined : size}
|
data-size={size === "md" ? undefined : size}
|
||||||
data-placement={placement}
|
data-placement={placement}
|
||||||
|
data-draggable={draggable ? "true" : undefined}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal={placement === "center" ? "true" : undefined}
|
aria-modal={placement === "center" ? "true" : undefined}
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={subtitle ? descriptionId : undefined}
|
aria-describedby={subtitle ? descriptionId : undefined}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
|
||||||
{...props}
|
{...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">
|
<div className="nodedc-window__titles">
|
||||||
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
|
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
|
||||||
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
|
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
|
<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>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="nodedc-window__body">{children}</div>
|
<div className="nodedc-window__body">{children}</div>
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,5 @@ export * from "./Select.js";
|
||||||
export * from "./StatusBadge.js";
|
export * from "./StatusBadge.js";
|
||||||
export * from "./Settings.js";
|
export * from "./Settings.js";
|
||||||
export * from "./SharingModals.js";
|
export * from "./SharingModals.js";
|
||||||
|
export * from "./Toolbar.js";
|
||||||
export * from "./Window.js";
|
export * from "./Window.js";
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,12 @@
|
||||||
"domExports": ["createModalController"],
|
"domExports": ["createModalController"],
|
||||||
"domContract": ["nodedc-overlay", "nodedc-window"],
|
"domContract": ["nodedc-overlay", "nodedc-window"],
|
||||||
"summary": "Canonical controlled window for modal and right-side panel presentations.",
|
"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": [
|
"rules": [
|
||||||
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
"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.",
|
"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."
|
"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",
|
"id": "icon",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
|
|
@ -216,7 +232,7 @@
|
||||||
"variants": ["14 px", "16 px", "18 px"],
|
"variants": ["14 px", "16 px", "18 px"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Use semantic names from registry/icons.json.",
|
"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."
|
"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