Refine catalog layout and logo media
This commit is contained in:
+113
-49
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user