Refine catalog layout and logo media
This commit is contained in:
parent
406e692a5e
commit
2907244caf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}` });
|
||||
|
|
|
|||
Loading…
Reference in New Issue