feat(control-station): configure page-specific shell
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
@@ -14,11 +15,16 @@ import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaKind,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { roots, type RootId } from "../productModel";
|
||||
import {
|
||||
roots,
|
||||
workspaces,
|
||||
workspacesForRoot,
|
||||
} from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
open: boolean;
|
||||
@@ -33,52 +39,39 @@ interface EnvironmentSettingsWindowProps {
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const headerLabelFields: readonly { id: RootId; label: string }[] = [
|
||||
{ id: "center", label: "Центр" },
|
||||
{ id: "fleet", label: "Парк" },
|
||||
{ id: "observation", label: "Наблюдение" },
|
||||
{ id: "missions", label: "Миссии" },
|
||||
{ id: "data", label: "Данные" },
|
||||
{ id: "system", label: "Система" },
|
||||
{ id: "polygon", label: "Тестировочный контур" },
|
||||
];
|
||||
|
||||
const backgroundSurfaceOptions: Array<{
|
||||
value: EnvironmentSurfaceId;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "home",
|
||||
label: "Mission Core",
|
||||
description: "Главная страница продукта",
|
||||
},
|
||||
...roots.map((root) => ({
|
||||
value: root.id,
|
||||
label: root.title,
|
||||
description: `Стартовая страница раздела «${root.label}»`,
|
||||
})),
|
||||
];
|
||||
|
||||
function inferMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
backgrounds: {
|
||||
...draft.backgrounds,
|
||||
[surfaceId]: {
|
||||
...draft.backgrounds[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
@@ -91,7 +84,7 @@ export function EnvironmentSettingsWindow({
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
@@ -101,20 +94,48 @@ export function EnvironmentSettingsWindow({
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedBackground = draft.backgrounds[surfaceId];
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = useMemo(() => [
|
||||
{
|
||||
value: "home" as const,
|
||||
label: draft.pages.home.headerLabel,
|
||||
description: "Главная страница продукта",
|
||||
},
|
||||
...roots.map((root) => ({
|
||||
value: root.id,
|
||||
label: draft.pages[root.id].headerLabel,
|
||||
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
|
||||
})),
|
||||
], [draft.pages]);
|
||||
const previewKind = selectedBackground.mediaKind
|
||||
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
|
||||
const quickActionWorkspaces = useMemo(
|
||||
() => surfaceId === "home"
|
||||
? workspaces.filter((workspace) => !workspace.internalOnly)
|
||||
: workspacesForRoot(surfaceId),
|
||||
[surfaceId],
|
||||
);
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "saving" || uploading;
|
||||
|
||||
const updateHeaderLabel = (rootId: RootId, value: string) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
headerLabels: { ...current.headerLabels, [rootId]: value },
|
||||
}));
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const uploadFile = async (file?: File) => {
|
||||
@@ -140,19 +161,36 @@ export function EnvironmentSettingsWindow({
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
|
||||
if (blank) {
|
||||
setLocalError(`Название «${blank.label}» не может быть пустым.`);
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedPage.primaryWorkspaceId
|
||||
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
|
||||
) {
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
|
||||
id,
|
||||
draft.headerLabels[id].trim(),
|
||||
])) as Record<RootId, string>,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
@@ -207,31 +245,13 @@ export function EnvironmentSettingsWindow({
|
||||
>
|
||||
<div className="environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ШАПКА"
|
||||
title="Названия разделов"
|
||||
description="Подписи применяются к верхней навигации. Продуктовые идентификаторы и маршруты не меняются."
|
||||
>
|
||||
<div className="environment-settings__labels">
|
||||
{headerLabelFields.map((field) => (
|
||||
<TextField
|
||||
key={field.id}
|
||||
label={field.label}
|
||||
value={draft.headerLabels[field.id]}
|
||||
maxLength={40}
|
||||
onChange={(event) => updateHeaderLabel(field.id, event.currentTarget.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
eyebrow="ПОДЛОЖКИ"
|
||||
title="Фото или видео стартовой страницы"
|
||||
description="Медиа заполняет выбранную стартовую страницу с автокадрированием. Рабочие поверхности и viewer не затрагиваются."
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать"
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.url) {
|
||||
setLocalError("Сначала загрузите файл или укажите URL.");
|
||||
@@ -244,13 +264,13 @@ export function EnvironmentSettingsWindow({
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__media">
|
||||
<div className="environment-settings__editor">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Экран</span>
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
label="Выбрать стартовую страницу"
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={backgroundSurfaceOptions}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
@@ -259,6 +279,72 @@ export function EnvironmentSettingsWindow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__copy">
|
||||
<TextField
|
||||
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaSourceField
|
||||
label="Видео / картинка"
|
||||
kindLabel={previewKind ?? "media"}
|
||||
|
||||
Reference in New Issue
Block a user