feat(ui): add environment media playlists

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 14:20:19 +03:00
parent c455301764
commit 8b1f109b09
11 changed files with 1076 additions and 138 deletions
@@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from "react";
import type {
EnvironmentBackground,
EnvironmentMediaItem,
} from "../core/environment/environmentSettings";
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
url: string;
mediaKind: "image" | "video";
};
function isReadyMediaItem(
item: EnvironmentMediaItem,
): item is ReadyEnvironmentMediaItem {
return Boolean(item.url && item.mediaKind);
}
export function EnvironmentBackgroundMedia({
background,
}: {
background: EnvironmentBackground;
}) {
const items = useMemo(
() => background.items.filter(isReadyMediaItem),
[background.items],
);
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
const [activeIndex, setActiveIndex] = useState(0);
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
const playableItems = items.filter((item) => !failedIds.has(item.id));
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
useEffect(() => {
setActiveIndex(0);
setFailedIds(new Set());
}, [playlistIdentity]);
useEffect(() => {
if (
!background.enabled
|| !activeItem
|| activeItem.mediaKind !== "image"
|| playableItems.length < 2
) {
return;
}
const timer = window.setTimeout(() => {
setActiveIndex((current) => (current + 1) % playableItems.length);
}, background.imageDurationSeconds * 1_000);
return () => window.clearTimeout(timer);
}, [
activeItem,
background.enabled,
background.imageDurationSeconds,
playableItems.length,
]);
if (!background.enabled || !activeItem) return null;
return (
<div className="landing-stage__media" aria-hidden="true">
{activeItem.mediaKind === "video" ? (
<video
key={activeItem.url}
src={activeItem.url}
autoPlay
muted
loop={playableItems.length === 1}
playsInline
onEnded={() => setActiveIndex((current) => (
(current + 1) % playableItems.length
))}
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
) : (
<img
key={activeItem.url}
src={activeItem.url}
alt=""
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "@nodedc/ui-react";
import {
createEnvironmentMediaItem,
inferEnvironmentMediaKind,
maxEnvironmentMediaItems,
type EnvironmentBackground,
type EnvironmentMediaItem,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
interface EnvironmentMediaPlaylistEditorProps {
surfaceId: EnvironmentSurfaceId;
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
const acceptedEnvironmentMedia = [
"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",
].join(",");
function patchItem(
background: EnvironmentBackground,
itemId: string,
patch: Partial<EnvironmentMediaItem>,
): EnvironmentBackground {
return {
...background,
items: background.items.map((item) => (
item.id === itemId ? { ...item, ...patch } : item
)),
};
}
export function EnvironmentMediaPlaylistEditor({
surfaceId,
background,
disabled,
error,
onChange,
onBusyChange,
onUpload,
}: EnvironmentMediaPlaylistEditorProps) {
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const backgroundRef = useRef(background);
backgroundRef.current = background;
const displayedItems = useMemo(
() => [...background.items].reverse(),
[background.items],
);
useEffect(() => {
onBusyChange(uploadingIds.size > 0);
}, [onBusyChange, uploadingIds.size]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
const setItemError = (itemId: string, message?: string) => {
setItemErrors((current) => {
const next = { ...current };
if (message) next[itemId] = message;
else delete next[itemId];
return next;
});
};
const uploadFile = async (itemId: string, file?: File) => {
if (!file) return;
setUploadingIds((current) => new Set(current).add(itemId));
setItemError(itemId);
try {
const uploaded = await onUpload(surfaceId, itemId, file);
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setItemError(
itemId,
reason instanceof Error
? reason.message
: "Не удалось загрузить медиаконтент.",
);
} finally {
setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="environment-media-playlist">
<div className="environment-media-playlist__head">
<div>
<span>Видео / картинка</span>
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
</div>
<IconButton
label="Добавить медиаконтент"
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
onClick={() => onChange({
...background,
items: [...background.items, createEnvironmentMediaItem()],
})}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="environment-media-playlist__items"
onReorder={(items) => onChange({
...background,
items: [...items].reverse(),
})}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex(
(candidate) => candidate.id === item.id,
);
return (
<div className="environment-media-playlist__item">
<MediaSourceField
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
kindLabel={item.mediaKind ?? "media"}
source={item.source}
url={item.url ?? ""}
fileName={item.fileName}
uploading={uploadingIds.has(item.id)}
previewSrc={item.url}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
error={itemErrors[item.id] ?? (
playbackIndex === background.items.length - 1 ? error : null
)}
onSourceChange={(source) => {
if (source === item.source) return;
setItemError(item.id);
onChange(patchItem(background, item.id, {
source,
url: null,
mediaKind: null,
fileName: null,
}));
}}
onUrlChange={(url) => {
setItemError(item.id);
onChange(patchItem(background, item.id, {
source: "url",
url: url || null,
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
fileName: null,
}));
}}
onFileChange={(file) => void uploadFile(item.id, file)}
/>
<div className="environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
const items = background.items.filter(
(candidate) => candidate.id !== item.id,
);
onChange({
...background,
enabled: items.length ? background.enabled : false,
items,
});
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="environment-media-playlist__empty">
Добавьте первый файл или прямую ссылку на медиаконтент.
</p>
{error ? (
<p className="environment-media-playlist__error" role="alert">
{error}
</p>
) : null}
</>
)}
<div className="environment-media-playlist__timing">
<RangeControl
label="Показывать изображение"
value={background.imageDurationSeconds}
min={1}
max={60}
step={1}
disabled={disabled}
formatValue={(value) => `${value} с`}
onChange={(imageDurationSeconds) => onChange({
...background,
imageDurationSeconds,
})}
/>
<span>
Новые элементы появляются сверху. Воспроизведение начинается снизу;
перетаскивание меняет порядок.
</span>
</div>
</div>
);
}
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
import {
Button,
FeatureSettingsWindow,
MediaSourceField,
Select,
SettingsCard,
Switch,
@@ -14,12 +13,12 @@ import {
import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentMediaKind,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import {
roots,
workspaces,
@@ -35,14 +34,11 @@ interface EnvironmentSettingsWindowProps {
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
function inferMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
function patchPage(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
@@ -108,8 +104,6 @@ export function EnvironmentSettingsWindow({
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)
@@ -138,28 +132,6 @@ export function EnvironmentSettingsWindow({
setDraft((current) => patchPage(current, surfaceId, patch));
};
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 invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
@@ -178,6 +150,26 @@ export function EnvironmentSettingsWindow({
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
(page.background.enabled && !page.background.items.length)
|| page.background.items.some((item) => {
if (!item.url || !item.mediaKind) return true;
if (item.source !== "url") return false;
try {
const parsed = new URL(item.url);
return !["http:", "https:"].includes(parsed.protocol);
} catch {
return true;
}
})
));
if (invalidMedia) {
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
setLocalError(
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
);
return;
}
setLocalError(null);
try {
await onSave({
@@ -253,8 +245,8 @@ export function EnvironmentSettingsWindow({
checked={selectedBackground.enabled}
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.url) {
setLocalError("Сначала загрузите файл или укажите URL.");
if (enabled && !selectedBackground.items.length) {
setLocalError("Сначала добавьте медиаконтент.");
return;
}
setLocalError(null);
@@ -345,29 +337,18 @@ export function EnvironmentSettingsWindow({
</div>
</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)."
<EnvironmentMediaPlaylistEditor
surfaceId={surfaceId}
background={selectedBackground}
disabled={busy}
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}
onBusyChange={setUploading}
onChange={(background) => {
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, background));
}}
onUpload={onUpload}
/>
</div>
</SettingsCard>
@@ -4,6 +4,7 @@ import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps {
root: RootDefinition | null;
@@ -25,28 +26,16 @@ export function LandingStage({
onOpenWorkspace,
}: LandingStageProps) {
const { background } = page;
const hasMedia = background.enabled && background.items.some(
(item) => item.url && item.mediaKind,
);
return (
<section
className="landing-stage"
data-root={root?.id ?? "home"}
data-has-media={background.enabled && background.url ? "true" : undefined}
data-has-media={hasMedia ? "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}
<EnvironmentBackgroundMedia background={background} />
<div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{page.eyebrow}</span>
@@ -4,14 +4,20 @@ export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentBackground {
enabled: boolean;
export interface EnvironmentMediaItem {
id: string;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
@@ -29,6 +35,7 @@ export interface EnvironmentSettings {
export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId;
itemId: string;
url: string;
fileName: string;
mediaKind: EnvironmentMediaKind;
@@ -47,9 +54,20 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon",
];
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
@@ -57,6 +75,10 @@ function emptyBackground(): EnvironmentBackground {
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
const defaultQuickActions: Record<
EnvironmentSurfaceId,
readonly [string | null, string | null]
@@ -110,26 +132,57 @@ function requireString(
return value;
}
function decodeMediaItem(value: unknown, path: string): EnvironmentMediaItem {
const record = requireRecord(value, path);
if (
typeof record.id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.id)
) {
throw new Error(`${path}.id не поддерживается.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error(`${path}.media_kind не поддерживается.`);
}
return {
id: record.id,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`),
mediaKind: record.media_kind as EnvironmentMediaKind,
fileName: requireString(record.file_name, `${path}.file_name`, true),
};
}
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") {
throw new Error(`${path}.enabled должен быть boolean.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
if (
typeof record.image_duration_seconds !== "number"
|| !Number.isSafeInteger(record.image_duration_seconds)
|| record.image_duration_seconds < 1
|| record.image_duration_seconds > 300
) {
throw new Error(`${path}.image_duration_seconds некорректен.`);
}
if (
record.media_kind !== null
&& !["image", "video"].includes(String(record.media_kind))
!Array.isArray(record.items)
|| record.items.length > maxEnvironmentMediaItems
) {
throw new Error(`${path}.media_kind не поддерживается.`);
throw new Error(`${path}.items некорректен.`);
}
const items = record.items.map((item, index) =>
decodeMediaItem(item, `${path}.items[${index}]`));
if (new Set(items.map((item) => item.id)).size !== items.length) {
throw new Error(`${path}.items содержит повторяющиеся id.`);
}
return {
enabled: record.enabled,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`, true),
mediaKind: record.media_kind as EnvironmentMediaKind | null,
fileName: requireString(record.file_name, `${path}.file_name`, true),
imageDurationSeconds: record.image_duration_seconds,
items,
};
}
@@ -156,7 +209,7 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v2") {
if (record.schema_version !== "missioncore.operator-environment/v3") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
@@ -190,10 +243,14 @@ export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknow
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
image_duration_seconds: page.background.imageDurationSeconds,
items: page.background.items.map((item) => ({
id: item.id,
source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
},
}];
})),
@@ -204,12 +261,18 @@ export function decodeUploadedEnvironmentMedia(
value: unknown,
): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media");
if (record.schema_version !== "missioncore.operator-environment-media/v1") {
if (record.schema_version !== "missioncore.operator-environment-media/v2") {
throw new Error("Версия загруженного media не поддерживается.");
}
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается.");
}
if (
typeof record.item_id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.item_id)
) {
throw new Error("Идентификатор загруженного media не поддерживается.");
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается.");
}
@@ -222,6 +285,7 @@ export function decodeUploadedEnvironmentMedia(
}
return {
surfaceId: record.surface_id as EnvironmentSurfaceId,
itemId: record.item_id,
url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind,
@@ -240,7 +304,12 @@ export function cloneEnvironmentSettings(
surfaceId,
{
...settings.pages[surfaceId],
background: { ...settings.pages[surfaceId].background },
background: {
...settings.pages[surfaceId].background,
items: settings.pages[surfaceId].background.items.map((item) => ({
...item,
})),
},
},
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
@@ -17,6 +17,7 @@ interface EnvironmentSettingsController {
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
@@ -101,17 +102,21 @@ export function useEnvironmentSettings(): EnvironmentSettingsController {
const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => {
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
const response = await fetch(
`/api/v1/environment/media/${surfaceId}/${itemId}`,
{
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
},
body: file,
});
);
if (!response.ok) {
throw new Error(await responseError(
response,
@@ -54,6 +54,72 @@
width: 100%;
}
.environment-media-playlist {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.environment-media-playlist__head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.environment-media-playlist__head > div {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.environment-media-playlist__head span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.environment-media-playlist__head p,
.environment-media-playlist__empty,
.environment-media-playlist__timing > span,
.environment-media-playlist__error {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.environment-media-playlist__error {
color: rgb(var(--nodedc-danger-rgb));
}
.environment-media-playlist__items {
display: grid;
min-width: 0;
gap: 0.7rem;
}
.environment-media-playlist__item {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.4rem;
}
.environment-media-playlist__item-actions {
display: flex;
align-items: center;
gap: 0.1rem;
padding-top: 1.65rem;
}
.environment-media-playlist__timing {
display: grid;
gap: 0.42rem;
}
@media (max-width: 760px) {
.environment-settings__copy,
.environment-settings__quick-actions {
@@ -63,4 +129,13 @@
.environment-settings__surface {
grid-template-columns: 1fr;
}
.environment-media-playlist__item {
grid-template-columns: minmax(0, 1fr);
}
.environment-media-playlist__item-actions {
justify-self: end;
padding-top: 0;
}
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
let server;
let backgroundMedia;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
backgroundMedia = await server.ssrLoadModule(
"/src/components/EnvironmentBackgroundMedia.tsx",
);
});
after(async () => {
await server?.close();
});
test("background playlist starts with its oldest media item", () => {
const markup = renderToStaticMarkup(
React.createElement(backgroundMedia.EnvironmentBackgroundMedia, {
background: {
enabled: true,
imageDurationSeconds: 10,
items: [
{
id: "media-oldest",
source: "url",
url: "https://example.test/oldest.png",
mediaKind: "image",
fileName: null,
},
{
id: "media-newest",
source: "url",
url: "https://example.test/newest.mp4",
mediaKind: "video",
fileName: null,
},
],
},
}),
);
assert.match(markup, /oldest\.png/);
assert.doesNotMatch(markup, /newest\.mp4/);
});
test("disabled background renders no media", () => {
const markup = renderToStaticMarkup(
React.createElement(backgroundMedia.EnvironmentBackgroundMedia, {
background: {
enabled: false,
imageDurationSeconds: 10,
items: [
{
id: "media-hidden",
source: "url",
url: "https://example.test/hidden.png",
mediaKind: "image",
fileName: null,
},
],
},
}),
);
assert.equal(markup, "");
});
@@ -24,7 +24,7 @@ after(async () => {
function serverDocument(overrides = {}) {
const defaults = environment.defaultEnvironmentSettings();
return {
schema_version: "missioncore.operator-environment/v2",
schema_version: "missioncore.operator-environment/v3",
revision: defaults.revision,
pages: Object.fromEntries(
Object.entries(defaults.pages).map(([surfaceId, page]) => [
@@ -38,10 +38,14 @@ function serverDocument(overrides = {}) {
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
image_duration_seconds: page.background.imageDurationSeconds,
items: page.background.items.map((item) => ({
id: item.id,
source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
},
},
]),
@@ -81,10 +85,23 @@ test("server document decodes and re-encodes the complete page contract", () =>
primary_workspace_id: "contour-health",
background: {
enabled: true,
source: "file",
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`,
media_kind: "video",
file_name: "stage.mp4",
image_duration_seconds: 12,
items: [
{
id: "media-11111111-1111-4111-8111-111111111111",
source: "file",
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`,
media_kind: "video",
file_name: "stage.mp4",
},
{
id: "media-22222222-2222-4222-8222-222222222222",
source: "url",
url: "https://example.test/background.png",
media_kind: "image",
file_name: null,
},
],
},
},
},
@@ -95,8 +112,11 @@ test("server document decodes and re-encodes the complete page contract", () =>
assert.equal(decoded.revision, 4);
assert.equal(decoded.pages.observation.headerLabel, "Контроль");
assert.equal(decoded.pages.observation.title, "Контроль пространства");
assert.equal(decoded.pages.fleet.background.mediaKind, "video");
assert.equal(encoded.pages.fleet.background.media_kind, "video");
assert.equal(decoded.pages.fleet.background.items.length, 2);
assert.equal(decoded.pages.fleet.background.items[0].mediaKind, "video");
assert.equal(decoded.pages.fleet.background.imageDurationSeconds, 12);
assert.equal(encoded.pages.fleet.background.items[1].media_kind, "image");
assert.equal(encoded.pages.fleet.background.image_duration_seconds, 12);
assert.equal(encoded.pages.fleet.primary_workspace_id, "contour-health");
assert.equal("schema_version" in encoded, false);
});
@@ -115,7 +135,11 @@ test("editing a cloned page cannot mutate accepted settings", () => {
const draft = environment.cloneEnvironmentSettings(accepted);
draft.pages.observation.headerLabel = "Изменено";
draft.pages.fleet.background.enabled = true;
draft.pages.fleet.background.items.push(
environment.createEnvironmentMediaItem(),
);
assert.equal(accepted.pages.observation.headerLabel, "Наблюдение");
assert.equal(accepted.pages.fleet.background.enabled, false);
assert.equal(accepted.pages.fleet.background.items.length, 0);
});