356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Button,
|
|
FeatureSettingsWindow,
|
|
Select,
|
|
SettingsCard,
|
|
Switch,
|
|
TextAreaField,
|
|
TextField,
|
|
WindowFooterActions,
|
|
} from "./index.js";
|
|
|
|
import {
|
|
cloneEnvironmentSettings,
|
|
type EnvironmentBackground,
|
|
type EnvironmentPage,
|
|
type EnvironmentSettings,
|
|
type EnvironmentSurface,
|
|
type UploadedEnvironmentMedia,
|
|
} from "@nodedc/ui-core";
|
|
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor.js";
|
|
|
|
export interface EnvironmentSettingsWindowProps {
|
|
productName: string;
|
|
surfaces: readonly EnvironmentSurface[];
|
|
initialSurfaceId?: string;
|
|
open: boolean;
|
|
settings: EnvironmentSettings;
|
|
state: "loading" | "ready" | "saving" | "error";
|
|
error: string | null;
|
|
onClose: () => void;
|
|
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
|
onUpload: (
|
|
surfaceId: string,
|
|
itemId: string,
|
|
file: File,
|
|
) => Promise<UploadedEnvironmentMedia>;
|
|
}
|
|
|
|
function patchPage(
|
|
draft: EnvironmentSettings,
|
|
surfaceId: string,
|
|
patch: Partial<EnvironmentPage>,
|
|
): EnvironmentSettings {
|
|
return {
|
|
...draft,
|
|
pages: {
|
|
...draft.pages,
|
|
[surfaceId]: {
|
|
...draft.pages[surfaceId],
|
|
...patch,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function patchBackground(
|
|
draft: EnvironmentSettings,
|
|
surfaceId: string,
|
|
patch: Partial<EnvironmentBackground>,
|
|
): EnvironmentSettings {
|
|
const page = draft.pages[surfaceId];
|
|
return patchPage(draft, surfaceId, {
|
|
background: {
|
|
...page.background,
|
|
...patch,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function EnvironmentSettingsWindow({
|
|
productName,
|
|
surfaces,
|
|
initialSurfaceId,
|
|
open,
|
|
settings,
|
|
state,
|
|
error,
|
|
onClose,
|
|
onSave,
|
|
onUpload,
|
|
}: EnvironmentSettingsWindowProps) {
|
|
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
|
const [surfaceId, setSurfaceId] = useState(initialSurfaceId ?? surfaces[0]?.id ?? "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 selectedPage = draft.pages[surfaceId];
|
|
const selectedBackground = selectedPage.background;
|
|
const pageOptions = surfaces.map(surface => ({ value: surface.id, label: draft.pages[surface.id].headerLabel, description: surface.description }));
|
|
const selectedSurface = surfaces.find(surface => surface.id === surfaceId) ?? surfaces[0];
|
|
const quickActionWorkspaces = selectedSurface?.actions ?? [];
|
|
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 === "loading" || state === "saving" || uploading;
|
|
|
|
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
|
setDraft((current) => patchPage(current, surfaceId, patch));
|
|
};
|
|
|
|
const save = async () => {
|
|
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
|
!page.headerLabel.trim()
|
|
|| !page.eyebrow.trim()
|
|
|| !page.title.trim()
|
|
|| !page.description.trim()
|
|
));
|
|
if (invalid) {
|
|
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
|
return;
|
|
}
|
|
const duplicateActions = Object.entries(draft.pages).find(([, page]) => (
|
|
page.primaryWorkspaceId && page.primaryWorkspaceId === page.secondaryWorkspaceId
|
|
));
|
|
if (duplicateActions) {
|
|
setSurfaceId(duplicateActions[0]);
|
|
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 string);
|
|
setLocalError(
|
|
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
|
);
|
|
return;
|
|
}
|
|
setLocalError(null);
|
|
try {
|
|
await onSave({
|
|
...draft,
|
|
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) {
|
|
setLocalError(reason instanceof Error
|
|
? reason.message
|
|
: "Не удалось сохранить настройки окружения.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<FeatureSettingsWindow
|
|
open={open}
|
|
title={`Настройки ${productName}`}
|
|
subtitle="Локальное операторское окружение"
|
|
identity={{
|
|
title: "DC",
|
|
subtitle: productName,
|
|
avatarLabel: "DC",
|
|
}}
|
|
sections={[
|
|
{
|
|
id: "environment",
|
|
label: "Окружение",
|
|
group: productName.toUpperCase(),
|
|
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"
|
|
tone="neutral"
|
|
disabled={!dirty || busy}
|
|
onClick={() => void save()}
|
|
>
|
|
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
|
</Button>
|
|
</WindowFooterActions>
|
|
)}
|
|
>
|
|
<div className="nodedc-environment-settings">
|
|
<SettingsCard
|
|
eyebrow="ОКРУЖЕНИЕ"
|
|
title="Основные элементы управления"
|
|
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
|
actions={(
|
|
<Switch
|
|
disabled={busy}
|
|
checked={selectedBackground.enabled}
|
|
label="Показывать фон"
|
|
onChange={(enabled) => {
|
|
if (enabled && !selectedBackground.items.length) {
|
|
setLocalError("Сначала добавьте медиаконтент.");
|
|
return;
|
|
}
|
|
setLocalError(null);
|
|
setDraft((current) =>
|
|
patchBackground(current, surfaceId, { enabled }));
|
|
}}
|
|
/>
|
|
)}
|
|
>
|
|
<div className="nodedc-environment-settings__editor">
|
|
<div className="nodedc-environment-settings__surface">
|
|
<span>Страница</span>
|
|
<Select
|
|
disabled={busy}
|
|
label="Выбрать страницу окружения"
|
|
value={surfaceId}
|
|
options={pageOptions}
|
|
variant="split"
|
|
menuWidth="anchor"
|
|
onChange={(value) => {
|
|
setSurfaceId(value);
|
|
setLocalError(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="nodedc-environment-settings__copy">
|
|
<TextField
|
|
disabled={busy}
|
|
label={selectedSurface?.home ? "Название продукта" : "Название в шапке"}
|
|
value={selectedPage.headerLabel}
|
|
maxLength={40}
|
|
onChange={(event) => updatePage({
|
|
headerLabel: event.currentTarget.value,
|
|
})}
|
|
/>
|
|
<TextField
|
|
disabled={busy}
|
|
label="Надзаголовок"
|
|
value={selectedPage.eyebrow}
|
|
maxLength={80}
|
|
onChange={(event) => updatePage({
|
|
eyebrow: event.currentTarget.value,
|
|
})}
|
|
/>
|
|
<TextField
|
|
disabled={busy}
|
|
label="Основной заголовок"
|
|
value={selectedPage.title}
|
|
maxLength={120}
|
|
onChange={(event) => updatePage({
|
|
title: event.currentTarget.value,
|
|
})}
|
|
/>
|
|
<TextAreaField
|
|
disabled={busy}
|
|
label="Описание"
|
|
value={selectedPage.description}
|
|
maxLength={500}
|
|
rows={3}
|
|
onChange={(event) => updatePage({
|
|
description: event.currentTarget.value,
|
|
})}
|
|
/>
|
|
</div>
|
|
|
|
<div className="nodedc-environment-settings__quick-actions">
|
|
<div>
|
|
<span>Кнопка 1</span>
|
|
<Select
|
|
disabled={busy}
|
|
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
|
|
disabled={busy}
|
|
label="Выбрать вторую быструю кнопку"
|
|
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
|
options={quickActionOptions}
|
|
variant="split"
|
|
menuWidth="anchor"
|
|
onChange={(value) => updatePage({
|
|
secondaryWorkspaceId: value === "none" ? null : value,
|
|
})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<EnvironmentMediaPlaylistEditor
|
|
key={surfaceId}
|
|
surfaceId={surfaceId}
|
|
background={selectedBackground}
|
|
disabled={busy}
|
|
error={localError ?? error}
|
|
onBusyChange={setUploading}
|
|
onChange={(background) => {
|
|
setLocalError(null);
|
|
setDraft((current) =>
|
|
patchBackground(current, surfaceId, background));
|
|
}}
|
|
onUpload={onUpload}
|
|
/>
|
|
</div>
|
|
</SettingsCard>
|
|
</div>
|
|
</FeatureSettingsWindow>
|
|
);
|
|
}
|