Refine catalog layout and logo media

This commit is contained in:
DCCONSTRUCTIONS 2026-07-11 10:04:31 +03:00
parent 406e692a5e
commit 2907244caf
8 changed files with 178 additions and 67 deletions

View File

@ -28,7 +28,6 @@ import {
ShareAccessModal,
ShareLinkModal,
SettingsCard,
StatusBadge,
Switch,
TextAreaField,
TextField,
@ -80,6 +79,10 @@ interface StoredLayout {
fileName?: string;
fileSrc?: string;
visible?: boolean;
logoSource?: "file" | "url";
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
};
toolbar?: {
placement?: ToolbarPlacement;
@ -296,6 +299,13 @@ export function CatalogApp() {
const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null);
const pendingMediaFileRef = useRef<File | null>(null);
const [logoSource, setLogoSource] = useState<"file" | "url">("file");
const [logoUrl, setLogoUrl] = useState("");
const [logoFileName, setLogoFileName] = useState("nodedc-mark.svg");
const [logoFileSrc, setLogoFileSrc] = useState("/nodedc-mark.svg");
const [logoError, setLogoError] = useState("");
const logoObjectUrlRef = useRef<string | null>(null);
const pendingLogoFileRef = useRef<File | null>(null);
const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
const [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117");
@ -316,6 +326,10 @@ export function CatalogApp() {
? mediaUrl
: null;
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc;
const externalLogoSrc = /^(https?:)?\/\//i.test(logoUrl) && /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(logoUrl)
? logoUrl
: null;
const headerMarkSrc = logoSource === "url" && externalLogoSrc ? externalLogoSrc : logoFileSrc;
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
const material = materialByTheme[theme];
@ -368,6 +382,10 @@ export function CatalogApp() {
if (media.fileName) setMediaFileName(media.fileName);
if (media.fileSrc) setFileMediaSrc(media.fileSrc);
if (typeof media.visible === "boolean") setMediaVisible(media.visible);
if (media.logoSource === "file" || media.logoSource === "url") setLogoSource(media.logoSource);
if (typeof media.logoUrl === "string") setLogoUrl(media.logoUrl);
if (media.logoFileName) setLogoFileName(media.logoFileName);
if (media.logoFileSrc) setLogoFileSrc(media.logoFileSrc);
}
const toolbar = stored.toolbar;
if (toolbar) {
@ -393,6 +411,7 @@ export function CatalogApp() {
useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
}, []);
const handleStageMediaFile = (file?: File) => {
@ -413,6 +432,23 @@ export function CatalogApp() {
setMediaError("");
};
const handleLogoFile = (file?: File) => {
if (!file) return;
const isImage = /^(image\/(png|jpeg|gif|webp))$/i.test(file.type) || /\.(png|jpe?g|gif|webp)$/i.test(file.name);
if (!isImage) {
setLogoError("Для знака нужен PNG, JPG, GIF или WEBP.");
return;
}
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
const objectUrl = URL.createObjectURL(file);
logoObjectUrlRef.current = objectUrl;
pendingLogoFileRef.current = file;
setLogoFileSrc(objectUrl);
setLogoFileName(file.name);
setLogoSource("file");
setLogoError("");
};
const inviteShareMember = () => {
const email = shareEmail.trim().toLowerCase();
if (!email) return;
@ -455,6 +491,21 @@ export function CatalogApp() {
setMediaFileName(uploaded.fileName);
pendingMediaFileRef.current = null;
}
let persistedLogoFileSrc = logoFileSrc;
const pendingLogo = pendingLogoFileRef.current;
if (pendingLogo) {
const uploadResponse = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": pendingLogo.type || "application/octet-stream", "x-file-name": encodeURIComponent(pendingLogo.name) },
body: pendingLogo,
});
if (!uploadResponse.ok) throw new Error("logo_upload_failed");
const uploaded = await uploadResponse.json() as { url: string; fileName: string };
persistedLogoFileSrc = uploaded.url;
setLogoFileSrc(uploaded.url);
setLogoFileName(uploaded.fileName);
pendingLogoFileRef.current = null;
}
const draft: StoredLayout = {
theme,
accentHex,
@ -477,6 +528,10 @@ export function CatalogApp() {
fileName: mediaFileName,
fileSrc: persistedFileSrc,
visible: mediaVisible,
logoSource,
logoUrl,
logoFileName,
logoFileSrc: persistedLogoFileSrc,
},
toolbar: {
placement: toolbarPlacement,
@ -616,33 +671,6 @@ export function CatalogApp() {
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>
<Button icon={<Icon name="refresh" />}>Обновить</Button>
<Button variant="ghost" icon={<Icon name="external" />}>Открыть</Button>
<Button variant="danger" icon={<Icon name="trash" />}>Удалить</Button>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
</div>
</Preview>
<Preview title="Поля" note="label / hint / control">
<div className="catalog-form">
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div>
</Preview>
<Preview title="Selection" note="integrated / split">
<div className="catalog-form">
<FieldFrame label="Hub / integrated select">
@ -676,15 +704,33 @@ export function CatalogApp() {
</Dropdown>
</div>
</Preview>
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
<div className="catalog-inline catalog-inline--wrap">
<StatusBadge>Черновик</StatusBadge>
<StatusBadge tone="success">Синхронизировано</StatusBadge>
<StatusBadge tone="warning">Нужна проверка</StatusBadge>
<StatusBadge tone="danger">Ошибка</StatusBadge>
<Preview title="Поля" note="label / hint / control">
<div className="catalog-form">
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div>
</Preview>
<Preview title="Действия" note="общая геометрия" className="catalog-preview--compact">
<div className="catalog-inline catalog-inline--wrap">
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
<Button icon={<Icon name="refresh" />}>Обновить</Button>
<Button variant="ghost" icon={<Icon name="external" />}>Открыть</Button>
<Button variant="danger" icon={<Icon name="trash" />}>Удалить</Button>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
</div>
</Preview>
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
<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="Inspector" note="modeless / draggable" className="catalog-preview--wide catalog-preview--compact catalog-inspector-launcher">
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
</Preview>
</div>
);
case "media":
@ -715,11 +761,30 @@ export function CatalogApp() {
onFileChange={handleStageMediaFile}
/>
</SettingsCard>
<Preview title="Серверное состояние" note="layout / media" className="catalog-preview--wide">
<p className="catalog-preview__explanation">
Save записывает один layout: обе темы, текущий accent, материалы, Environment controls, Toolbar и выбранный media-source. Очистка browser cache больше не удаляет конфигурацию.
</p>
</Preview>
<SettingsCard
eyebrow="BRAND"
title="Знак приложения"
description="CMS media-контракт для центрального workspace mark в верхней шапке."
>
<MediaSourceField
label="Логотип / изображение"
source={logoSource}
url={logoUrl}
fileName={logoFileName}
previewSrc={headerMarkSrc}
previewKind="image"
accept="image/png,image/jpeg,image/gif,image/webp,.png,.jpg,.jpeg,.gif,.webp"
path="header.workspaceMark → runtime media source"
hint="Изображение сразу меняет центральный знак в шапке и загружается на сервер общей кнопкой Save."
error={logoError}
onSourceChange={setLogoSource}
onUrlChange={(nextUrl) => {
setLogoUrl(nextUrl);
setLogoError(nextUrl && !/\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на изображение." : "");
}}
onFileChange={handleLogoFile}
/>
</SettingsCard>
</div>
);
case "modals":
@ -813,13 +878,20 @@ export function CatalogApp() {
brandHref="/"
center={
<>
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl="/nodedc-mark.svg" />
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl={headerMarkSrc} />
<HeaderNavigation
label="Навигация приложения"
value={guidelineOpen ? "guideline" : undefined}
items={[{ value: "guideline", label: "Guideline" }]}
onChange={() => guidelineOpen ? closeGuideline() : workspace.openNavigation()}
/>
<SegmentedControl
className="catalog-header-theme-switch"
label="Тема приложения"
value={theme}
items={[{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]}
onChange={setTheme}
/>
</>
}
right={
@ -874,14 +946,6 @@ 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: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
icon: "save",

View File

@ -29,7 +29,7 @@ textarea {
}
[data-nodedc-theme="light"] .catalog-app .nodedc-header__brand img,
[data-nodedc-theme="light"] .catalog-app .nodedc-header__workspace img {
[data-nodedc-theme="light"] .catalog-app .nodedc-header__workspace img[src="/nodedc-mark.svg"] {
filter: brightness(0.12);
}
@ -101,7 +101,7 @@ textarea {
display: grid;
align-content: start;
gap: 1rem;
padding: 0.15rem 0.1rem 1rem;
padding: 0.75rem 1rem 1.75rem;
}
.catalog-grid {
@ -118,10 +118,26 @@ textarea {
min-height: 16rem;
}
.catalog-preview--compact {
min-height: 10.25rem;
}
.catalog-preview--compact .catalog-preview__head {
margin-bottom: 0.85rem;
}
.catalog-preview--compact .catalog-preview__body {
min-height: 4.5rem;
}
.catalog-preview--wide {
grid-column: 1 / -1;
}
.catalog-inspector-launcher .nodedc-button {
width: 100%;
}
.catalog-preview__head {
display: flex;
align-items: baseline;
@ -214,7 +230,11 @@ textarea {
}
.nodedc-glass.catalog-modal-preview {
min-height: 10.5rem;
display: grid;
height: 11rem;
min-height: 11rem;
box-sizing: border-box;
grid-template-rows: auto minmax(0, 1fr);
padding: 1rem;
}
@ -223,13 +243,17 @@ textarea {
}
.catalog-modal-preview .catalog-preview__body {
height: auto;
min-height: 0;
align-content: space-between;
grid-template-rows: minmax(0, 1fr) auto;
align-content: stretch;
gap: 0.75rem;
}
.catalog-modal-preview .nodedc-button {
width: 100%;
align-self: end;
margin: 0;
padding-inline: 0.75rem;
}
@ -338,10 +362,14 @@ textarea {
height: 2.92rem;
place-items: center;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-glass-control-hover);
background: var(--nodedc-panel-item-bg);
color: var(--nodedc-text-primary);
}
.catalog-header-theme-switch {
box-shadow: inset 0 1px 0 color-mix(in srgb, white 22%, transparent), var(--nodedc-glass-control-shadow);
}
.catalog-icon-tile__surface > svg {
transform: scale(var(--nodedc-icon-glyph-scale));
}
@ -411,6 +439,10 @@ textarea {
display: none;
}
.catalog-header-theme-switch .nodedc-segmented__item {
padding-inline: 0.8rem;
}
.catalog-launcher-stage__title {
top: 2rem;
left: 2rem;

View File

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

View File

@ -4,7 +4,7 @@
NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику.
Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Пакеты дизайн-системы не владеют данными приложений. Living catalog имеет собственный минимальный runtime-store только для сохранения единого демонстрационного layout и загруженного stage-media между браузерными сессиями.
Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Пакеты дизайн-системы не владеют данными приложений. Living catalog имеет собственный минимальный runtime-store только для сохранения единого демонстрационного layout, загруженного stage-media и знака приложения между браузерными сессиями.
## Слои
@ -37,7 +37,7 @@ Core не зависит от React.
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar и media source. Browser localStorage не является источником истины.
Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar, stage-media и знак приложения. Browser localStorage не является источником истины.
## Граница shared/domain

View File

@ -136,9 +136,9 @@ Pill navigation для верхней панели и компактного п
## MediaSourceField
Общий компонент Launcher/CMS для медиа-поля: file control, имя файла, URL input, переключатель `HD / URL`, круглое превью, path/hint/error. Публичный API контролируемый.
Общий компонент Launcher/CMS для медиа-поля: file control, имя файла, URL input, переключатель `HD / URL`, круглое превью, path/hint/error. Один контракт применяется и для stage-видео, и для изображений/логотипов; тип файла, подписи и preview задаёт consumer. Публичный API контролируемый.
Компонент владеет геометрией, доступностью и выбором источника. Приложение владеет file picker/media library, загрузкой в storage, валидацией, разрешениями и сохраняемым URL. Поэтому один и тот же компонент подключается к разным backend без fork. Для Vanilla DOM используется `createMediaSourceController`.
Компонент владеет геометрией, доступностью и выбором источника. Приложение владеет file picker/media library, загрузкой в storage, валидацией, разрешениями и сохраняемым URL. Поэтому один и тот же компонент подключается к разным backend без fork. В каталоге видео и знак шапки сохраняются одной кнопкой вместе с темой и настройками layout. Для Vanilla DOM используется `createMediaSourceController`.
## SettingsCard и Switch

View File

@ -1100,8 +1100,9 @@ textarea.nodedc-field__control {
place-items: center;
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background: transparent;
background: var(--nodedc-panel-icon-bg);
color: var(--nodedc-text-primary);
box-shadow: var(--nodedc-glass-control-shadow);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
}
@ -1595,12 +1596,12 @@ textarea.nodedc-field__control {
background: var(--nodedc-panel-item-active-bg);
color: var(--nodedc-text-primary);
opacity: 1;
box-shadow: var(--nodedc-glass-control-shadow), 0 10px 22px rgba(24, 32, 29, 0.12);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 12%, transparent), inset 0 -14px 26px color-mix(in srgb, var(--nodedc-canvas) 8%, transparent);
}
.nodedc-admin-panel__nav-item:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 1px var(--nodedc-glass-outline), 0 10px 22px rgba(24, 32, 29, 0.12);
box-shadow: inset 0 0 0 1px var(--nodedc-glass-outline), inset 0 -14px 26px color-mix(in srgb, var(--nodedc-canvas) 8%, transparent);
}
.nodedc-app-shell__navigation > .nodedc-admin-panel {

View File

@ -258,12 +258,13 @@
"domPackage": "@nodedc/ui-dom",
"domExports": ["createMediaSourceController"],
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"],
"summary": "Launcher/CMS media control for file or URL sources, filename, preview and adapter-provided storage state.",
"summary": "Launcher/CMS media control for video, image or logo file/URL sources, filename, preview and adapter-provided storage state.",
"behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
"rules": [
"The component owns geometry, source switching and accessibility.",
"The consumer owns media library, storage upload, validation and the persisted URL.",
"Do not fork the field for a different backend; provide a different adapter callback."
"Do not fork the field by asset type or backend; configure its accepted media and provide an adapter callback.",
"Application layout persistence may save stage video and header mark together, but remains outside the visual component."
]
},
{

View File

@ -27,6 +27,8 @@ const mimeTypes = {
".png": "image/png",
".svg": "image/svg+xml",
".webm": "video/webm",
".webp": "image/webp",
".mov": "video/quicktime",
};
function json(response, statusCode, value) {
@ -45,8 +47,19 @@ async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function safeUploadName(name) {
const extension = extname(name).toLowerCase() === ".mp4" ? ".mp4" : ".mp4";
function safeUploadName(name, contentType = "") {
const allowed = new Set([".gif", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".webm", ".webp"]);
const contentTypeExtensions = {
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"video/webm": ".webm",
};
const requestedExtension = extname(name).toLowerCase();
const extension = allowed.has(requestedExtension) ? requestedExtension : contentTypeExtensions[contentType] || ".bin";
const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video";
return `${Date.now()}-${stem}${extension}`;
}
@ -108,7 +121,7 @@ const server = createServer(async (request, response) => {
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 fileName = safeUploadName(originalName, String(request.headers["content-type"] || ""));
const targetPath = join(uploadDir, fileName);
await pipeline(request, createWriteStream(targetPath, { flags: "wx" }));
json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` });