diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 4b02bb9..3aa8ab7 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -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(null); const pendingMediaFileRef = useRef(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(null); + const pendingLogoFileRef = useRef(null); const [toolbarPlacement, setToolbarPlacement] = useState("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 (
- -

Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.

- -
- -
- - - - -
-
- -
- - - - - -
-
- -
- setProjectName(event.target.value)} /> - setNotes(event.target.value)} /> -
-
@@ -676,15 +704,33 @@ export function CatalogApp() {
- -

Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.

-
- Черновик - Синхронизировано - Нужна проверка - Ошибка + +
+ setProjectName(event.target.value)} /> + setNotes(event.target.value)} />
+ +
+ + + + + +
+
+ +
+ + + + +
+
+ +

Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.

+ +
); case "media": @@ -715,11 +761,30 @@ export function CatalogApp() { onFileChange={handleStageMediaFile} /> - -

- Save записывает один layout: обе темы, текущий accent, материалы, Environment controls, Toolbar и выбранный media-source. Очистка browser cache больше не удаляет конфигурацию. -

-
+ + { + setLogoUrl(nextUrl); + setLogoError(nextUrl && !/\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на изображение." : ""); + }} + onFileChange={handleLogoFile} + /> +
); case "modals": @@ -813,13 +878,20 @@ export function CatalogApp() { brandHref="/" center={ <> - + guidelineOpen ? closeGuideline() : workspace.openNavigation()} /> + } right={ @@ -874,14 +946,6 @@ export function CatalogApp() { description={activeDefinition.description} expanded={panelExpanded} onExpandedChange={workspace.setContentExpanded} - headerTools={ - - } utilityActions={[{ label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер", icon: "save", diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index cd156f0..e351b57 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -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; diff --git a/docs/APPLICATION_TEMPLATE.md b/docs/APPLICATION_TEMPLATE.md index 86f2c4f..ddc02d2 100644 --- a/docs/APPLICATION_TEMPLATE.md +++ b/docs/APPLICATION_TEMPLATE.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6550b0..380cd83 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index ec0593c..4fd663f 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -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 diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index b5143e0..7f32af4 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -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 { diff --git a/registry/components.json b/registry/components.json index 4365803..27ec496 100644 --- a/registry/components.json +++ b/registry/components.json @@ -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." ] }, { diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index eb3d0c3..e7390ec 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -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}` });