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