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, ShareAccessModal,
ShareLinkModal, ShareLinkModal,
SettingsCard, SettingsCard,
StatusBadge,
Switch, Switch,
TextAreaField, TextAreaField,
TextField, TextField,
@ -80,6 +79,10 @@ interface StoredLayout {
fileName?: string; fileName?: string;
fileSrc?: string; fileSrc?: string;
visible?: boolean; visible?: boolean;
logoSource?: "file" | "url";
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
}; };
toolbar?: { toolbar?: {
placement?: ToolbarPlacement; placement?: ToolbarPlacement;
@ -296,6 +299,13 @@ export function CatalogApp() {
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 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 [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
const [toolbarBg, setToolbarBg] = useState("#111115"); const [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117"); const [toolbarBorder, setToolbarBorder] = useState("#111117");
@ -316,6 +326,10 @@ export function CatalogApp() {
? mediaUrl ? mediaUrl
: null; : null;
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc; 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 accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
const material = materialByTheme[theme]; const material = materialByTheme[theme];
@ -368,6 +382,10 @@ export function CatalogApp() {
if (media.fileName) setMediaFileName(media.fileName); if (media.fileName) setMediaFileName(media.fileName);
if (media.fileSrc) setFileMediaSrc(media.fileSrc); if (media.fileSrc) setFileMediaSrc(media.fileSrc);
if (typeof media.visible === "boolean") setMediaVisible(media.visible); 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; const toolbar = stored.toolbar;
if (toolbar) { if (toolbar) {
@ -393,6 +411,7 @@ export function CatalogApp() {
useEffect(() => () => { useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
}, []); }, []);
const handleStageMediaFile = (file?: File) => { const handleStageMediaFile = (file?: File) => {
@ -413,6 +432,23 @@ export function CatalogApp() {
setMediaError(""); 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 inviteShareMember = () => {
const email = shareEmail.trim().toLowerCase(); const email = shareEmail.trim().toLowerCase();
if (!email) return; if (!email) return;
@ -455,6 +491,21 @@ export function CatalogApp() {
setMediaFileName(uploaded.fileName); setMediaFileName(uploaded.fileName);
pendingMediaFileRef.current = null; 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 = { const draft: StoredLayout = {
theme, theme,
accentHex, accentHex,
@ -477,6 +528,10 @@ export function CatalogApp() {
fileName: mediaFileName, fileName: mediaFileName,
fileSrc: persistedFileSrc, fileSrc: persistedFileSrc,
visible: mediaVisible, visible: mediaVisible,
logoSource,
logoUrl,
logoFileName,
logoFileSrc: persistedLogoFileSrc,
}, },
toolbar: { toolbar: {
placement: toolbarPlacement, placement: toolbarPlacement,
@ -616,33 +671,6 @@ export function CatalogApp() {
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="общая геометрия">
<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"> <Preview title="Selection" note="integrated / split">
<div className="catalog-form"> <div className="catalog-form">
<FieldFrame label="Hub / integrated select"> <FieldFrame label="Hub / integrated select">
@ -676,15 +704,33 @@ export function CatalogApp() {
</Dropdown> </Dropdown>
</div> </div>
</Preview> </Preview>
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide"> <Preview title="Поля" note="label / hint / control">
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p> <div className="catalog-form">
<div className="catalog-inline catalog-inline--wrap"> <TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<StatusBadge>Черновик</StatusBadge> <TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
<StatusBadge tone="success">Синхронизировано</StatusBadge>
<StatusBadge tone="warning">Нужна проверка</StatusBadge>
<StatusBadge tone="danger">Ошибка</StatusBadge>
</div> </div>
</Preview> </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> </div>
); );
case "media": case "media":
@ -715,11 +761,30 @@ export function CatalogApp() {
onFileChange={handleStageMediaFile} onFileChange={handleStageMediaFile}
/> />
</SettingsCard> </SettingsCard>
<Preview title="Серверное состояние" note="layout / media" className="catalog-preview--wide"> <SettingsCard
<p className="catalog-preview__explanation"> eyebrow="BRAND"
Save записывает один layout: обе темы, текущий accent, материалы, Environment controls, Toolbar и выбранный media-source. Очистка browser cache больше не удаляет конфигурацию. title="Знак приложения"
</p> description="CMS media-контракт для центрального workspace mark в верхней шапке."
</Preview> >
<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> </div>
); );
case "modals": case "modals":
@ -813,13 +878,20 @@ export function CatalogApp() {
brandHref="/" brandHref="/"
center={ center={
<> <>
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl="/nodedc-mark.svg" /> <HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl={headerMarkSrc} />
<HeaderNavigation <HeaderNavigation
label="Навигация приложения" label="Навигация приложения"
value={guidelineOpen ? "guideline" : undefined} value={guidelineOpen ? "guideline" : undefined}
items={[{ value: "guideline", label: "Guideline" }]} items={[{ value: "guideline", label: "Guideline" }]}
onChange={() => guidelineOpen ? closeGuideline() : workspace.openNavigation()} 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={ right={
@ -874,14 +946,6 @@ 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: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер", label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
icon: "save", 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__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); filter: brightness(0.12);
} }
@ -101,7 +101,7 @@ textarea {
display: grid; display: grid;
align-content: start; align-content: start;
gap: 1rem; gap: 1rem;
padding: 0.15rem 0.1rem 1rem; padding: 0.75rem 1rem 1.75rem;
} }
.catalog-grid { .catalog-grid {
@ -118,10 +118,26 @@ textarea {
min-height: 16rem; 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 { .catalog-preview--wide {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.catalog-inspector-launcher .nodedc-button {
width: 100%;
}
.catalog-preview__head { .catalog-preview__head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@ -214,7 +230,11 @@ textarea {
} }
.nodedc-glass.catalog-modal-preview { .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; padding: 1rem;
} }
@ -223,13 +243,17 @@ textarea {
} }
.catalog-modal-preview .catalog-preview__body { .catalog-modal-preview .catalog-preview__body {
height: auto;
min-height: 0; min-height: 0;
align-content: space-between; grid-template-rows: minmax(0, 1fr) auto;
align-content: stretch;
gap: 0.75rem; gap: 0.75rem;
} }
.catalog-modal-preview .nodedc-button { .catalog-modal-preview .nodedc-button {
width: 100%; width: 100%;
align-self: end;
margin: 0;
padding-inline: 0.75rem; padding-inline: 0.75rem;
} }
@ -338,10 +362,14 @@ textarea {
height: 2.92rem; height: 2.92rem;
place-items: center; place-items: center;
border-radius: var(--nodedc-radius-circle); border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-glass-control-hover); background: var(--nodedc-panel-item-bg);
color: var(--nodedc-text-primary); 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 { .catalog-icon-tile__surface > svg {
transform: scale(var(--nodedc-icon-glyph-scale)); transform: scale(var(--nodedc-icon-glyph-scale));
} }
@ -411,6 +439,10 @@ textarea {
display: none; display: none;
} }
.catalog-header-theme-switch .nodedc-segmented__item {
padding-inline: 0.8rem;
}
.catalog-launcher-stage__title { .catalog-launcher-stage__title {
top: 2rem; top: 2rem;
left: 2rem; left: 2rem;

View File

@ -14,7 +14,7 @@
Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки. Действие, относящееся ко всему рабочему окну, передаётся через `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 ## Состояния desktop

View File

@ -4,7 +4,7 @@
NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику. 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 ## Граница shared/domain

View File

@ -136,9 +136,9 @@ Pill navigation для верхней панели и компактного п
## MediaSourceField ## 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 ## SettingsCard и Switch

View File

@ -1100,8 +1100,9 @@ textarea.nodedc-field__control {
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
border-radius: var(--nodedc-radius-circle); border-radius: var(--nodedc-radius-circle);
background: transparent; background: var(--nodedc-panel-icon-bg);
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
box-shadow: var(--nodedc-glass-control-shadow);
font-size: var(--nodedc-font-size-sm); font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong); font-weight: var(--nodedc-font-weight-strong);
} }
@ -1595,12 +1596,12 @@ textarea.nodedc-field__control {
background: var(--nodedc-panel-item-active-bg); background: var(--nodedc-panel-item-active-bg);
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
opacity: 1; 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 { .nodedc-admin-panel__nav-item:focus-visible {
outline: 0; 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 { .nodedc-app-shell__navigation > .nodedc-admin-panel {

View File

@ -258,12 +258,13 @@
"domPackage": "@nodedc/ui-dom", "domPackage": "@nodedc/ui-dom",
"domExports": ["createMediaSourceController"], "domExports": ["createMediaSourceController"],
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"], "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"], "behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
"rules": [ "rules": [
"The component owns geometry, source switching and accessibility.", "The component owns geometry, source switching and accessibility.",
"The consumer owns media library, storage upload, validation and the persisted URL.", "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", ".png": "image/png",
".svg": "image/svg+xml", ".svg": "image/svg+xml",
".webm": "video/webm", ".webm": "video/webm",
".webp": "image/webp",
".mov": "video/quicktime",
}; };
function json(response, statusCode, value) { 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")); return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} }
function safeUploadName(name) { function safeUploadName(name, contentType = "") {
const extension = extname(name).toLowerCase() === ".mp4" ? ".mp4" : ".mp4"; 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"; const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video";
return `${Date.now()}-${stem}${extension}`; return `${Date.now()}-${stem}${extension}`;
} }
@ -108,7 +121,7 @@ const server = createServer(async (request, response) => {
if (url.pathname === "/api/layout/media" && request.method === "PUT") { if (url.pathname === "/api/layout/media" && request.method === "PUT") {
const originalName = decodeURIComponent(String(request.headers["x-file-name"] || "stage-video.mp4")); 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); const targetPath = join(uploadDir, fileName);
await pipeline(request, createWriteStream(targetPath, { flags: "wx" })); await pipeline(request, createWriteStream(targetPath, { flags: "wx" }));
json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` }); json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` });