feat(control-station): add configurable environment shell
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
MediaSourceField,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaKind,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { roots, type RootId } from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
file: File,
|
||||
) => 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 patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
backgrounds: {
|
||||
...draft.backgrounds,
|
||||
[surfaceId]: {
|
||||
...draft.backgrounds[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedBackground = draft.backgrounds[surfaceId];
|
||||
const previewKind = selectedBackground.mediaKind
|
||||
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
|
||||
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 uploadFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, file);
|
||||
setDraft((current) => patchBackground(current, surfaceId, {
|
||||
enabled: true,
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить фон окружения.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
|
||||
if (blank) {
|
||||
setLocalError(`Название «${blank.label}» не может быть пустым.`);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
|
||||
id,
|
||||
draft.headerLabels[id].trim(),
|
||||
])) as Record<RootId, string>,
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Mission Core"
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: "Mission Core",
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: "MISSION CORE",
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<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 не затрагиваются."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.url) {
|
||||
setLocalError("Сначала загрузите файл или укажите URL.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__media">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Экран</span>
|
||||
<Select
|
||||
label="Выбрать стартовую страницу"
|
||||
value={surfaceId}
|
||||
options={backgroundSurfaceOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<MediaSourceField
|
||||
label="Видео / картинка"
|
||||
kindLabel={previewKind ?? "media"}
|
||||
source={selectedBackground.source}
|
||||
url={selectedBackground.url ?? ""}
|
||||
fileName={selectedBackground.fileName}
|
||||
uploading={uploading}
|
||||
previewSrc={selectedBackground.url}
|
||||
previewKind={previewKind}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,video/mp4,video/webm,video/quicktime,.png,.jpg,.jpeg,.gif,.webp,.avif,.mp4,.webm,.mov"
|
||||
path={`${surfaceId}.background → server environment media`}
|
||||
hint="Файл сохраняется в Mission Core data root. URL должен быть доступен браузеру по HTTP(S)."
|
||||
error={localError ?? error}
|
||||
onSourceChange={(source) => setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { source }))}
|
||||
onUrlChange={(url) => setDraft((current) =>
|
||||
patchBackground(current, surfaceId, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}))}
|
||||
onFileChange={uploadFile}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { EnvironmentBackground } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
|
||||
@@ -9,6 +10,7 @@ export interface LandingStageProps {
|
||||
backendStatus: BackendStatus;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
background: EnvironmentBackground;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
}
|
||||
@@ -18,11 +20,33 @@ export function LandingStage({
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
background,
|
||||
onOpenObservation,
|
||||
onOpenDevice,
|
||||
}: LandingStageProps) {
|
||||
return (
|
||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||
<section
|
||||
className="landing-stage"
|
||||
data-root={root?.id ?? "home"}
|
||||
data-has-media={background.enabled && background.url ? "true" : undefined}
|
||||
>
|
||||
{background.enabled && background.url ? (
|
||||
<div className="landing-stage__media" aria-hidden="true">
|
||||
{background.mediaKind === "video" ? (
|
||||
<video
|
||||
key={background.url}
|
||||
src={background.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img src={background.url} alt="" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="landing-stage__shade" aria-hidden="true" />
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||
<h1>{root?.title ?? "Mission Core"}</h1>
|
||||
|
||||
Reference in New Issue
Block a user