Persist catalog layout and add inspector toolbar
This commit is contained in:
+250
-120
@@ -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
|
||||
|
||||
+1
-141
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user