Extract shared home and environment settings for product reuse

This commit is contained in:
Codex
2026-09-07 22:54:17 +03:00
parent 26a1bf72a2
commit b10fd5d645
21 changed files with 1431 additions and 13 deletions
+4
View File
@@ -8,6 +8,8 @@ export type ButtonShape = "default" | "pill" | "rounded";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
/** Neutral primary actions remain white/gray independently of the product accent. */
tone?: "theme" | "neutral";
size?: ButtonSize;
width?: "auto" | "full";
shape?: ButtonShape;
@@ -17,6 +19,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
variant = "secondary",
tone = "theme",
size = "default",
width = "auto",
shape = "default",
@@ -35,6 +38,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
type={type}
className={cn("nodedc-button", className)}
data-variant={variant}
data-tone={tone === "theme" ? undefined : tone}
data-size={size === "default" ? undefined : size}
data-width={width === "auto" ? undefined : width}
data-shape={shape === "default" ? undefined : shape}
@@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from "react";
import type {
EnvironmentBackground,
EnvironmentMediaItem,
} from "@nodedc/ui-core";
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="nodedc-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,251 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "./index.js";
import {
appendEnvironmentMediaItem,
inferEnvironmentMediaKind,
maxEnvironmentMediaItems,
removeEnvironmentMediaItem,
type EnvironmentBackground,
type EnvironmentMediaItem,
type UploadedEnvironmentMedia,
} from "@nodedc/ui-core";
export interface EnvironmentMediaPlaylistEditorProps {
surfaceId: string;
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (
surfaceId: string,
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 mounted = useRef(true);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
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);
if (!mounted.current) return;
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
if (!mounted.current) return;
setItemError(
itemId,
reason instanceof Error
? reason.message
: "Не удалось загрузить медиаконтент.",
);
} finally {
if (mounted.current) setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="nodedc-environment-media-playlist">
<div className="nodedc-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(appendEnvironmentMediaItem(background))}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="nodedc-environment-media-playlist__items"
onReorder={(items) => !disabled && onChange({
...background,
items: [...items].reverse(),
})}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex(
(candidate) => candidate.id === item.id,
);
return (
<div className="nodedc-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}
disabled={disabled}
uploading={uploadingIds.has(item.id)}
previewSrc={item.url}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
hint="Файл сохраняется в приложении. Ссылка должна вести прямо на изображение или видео по 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="nodedc-environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
onChange(removeEnvironmentMediaItem(background, item.id));
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="nodedc-environment-media-playlist__empty">
Добавьте первый файл или прямую ссылку на медиаконтент.
</p>
{error ? (
<p className="nodedc-environment-media-playlist__error" role="alert">
{error}
</p>
) : null}
</>
)}
<div className="nodedc-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>
);
}
@@ -0,0 +1,355 @@
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>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import type { EnvironmentPage } from "@nodedc/ui-core";
import { Button } from "./Button.js";
import { Icon, type IconName } from "./Icon.js";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia.js";
export interface LandingStageProps {
page: EnvironmentPage;
pageId?: string;
actions?: readonly { id: string; label: string; icon?: IconName; onSelect: () => void }[];
status?: ReactNode;
footer?: ReactNode;
}
export function LandingStage({ page, pageId = "home", actions = [], status, footer }: LandingStageProps) {
const { background } = page;
const hasMedia = background.enabled && background.items.some(
(item) => item.url && item.mediaKind,
);
return (
<section
className="nodedc-landing-stage"
data-page={pageId}
data-has-media={hasMedia ? "true" : undefined}
>
<EnvironmentBackgroundMedia background={background} />
<div className="nodedc-landing-stage__shade" aria-hidden="true" />
<div className="nodedc-landing-stage__copy">
<span className="nodedc-landing-stage__eyebrow">{page.eyebrow}</span>
<h1>{page.title}</h1>
<p>{page.description}</p>
{actions.length ? (
<div className="nodedc-landing-stage__actions">
{actions.map((workspace, index) => (
<Button
key={workspace.id}
variant={index === 0 ? "primary" : "secondary"}
tone="neutral"
icon={workspace.icon ? <Icon name={workspace.icon} /> : undefined}
onClick={() => workspace.onSelect()}
>
{workspace.label}
</Button>
))}
</div>
) : null}
</div>
{status ? <div className="nodedc-landing-stage__status">{status}</div> : null}
{footer ? <footer className="nodedc-landing-stage__footer">{footer}</footer> : null}
</section>
);
}
+6 -1
View File
@@ -11,6 +11,7 @@ export interface MediaSourceFieldProps {
url: string;
fileName?: string | null;
uploading?: boolean;
disabled?: boolean;
previewSrc?: string | null;
previewKind?: MediaPreviewKind | null;
accept?: string;
@@ -43,6 +44,7 @@ export function MediaSourceField({
url,
fileName,
uploading = false,
disabled = false,
previewSrc,
previewKind,
accept = "image/*,video/*",
@@ -73,11 +75,12 @@ export function MediaSourceField({
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
<input id={inputId} type="file" accept={accept} disabled={disabled || uploading} onChange={handleFileChange} />
</div>
<input
className="nodedc-media-url"
type="url"
disabled={disabled || uploading}
value={url}
hidden={source !== "url"}
data-nodedc-media-source-panel="url"
@@ -89,6 +92,7 @@ export function MediaSourceField({
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
<button
type="button"
disabled={disabled || uploading}
className="nodedc-media-source-button"
data-active={source === "file" ? "true" : undefined}
data-nodedc-media-source-option="file"
@@ -98,6 +102,7 @@ export function MediaSourceField({
>HD</button>
<button
type="button"
disabled={disabled || uploading}
className="nodedc-media-source-button"
data-active={source === "url" ? "true" : undefined}
data-nodedc-media-source-option="url"
+4
View File
@@ -30,3 +30,7 @@ export * from "./Window.js";
export * from "./WorkspaceWindow.js";
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
export * from "./EnvironmentSettingsWindow.js";
export * from "./EnvironmentMediaPlaylistEditor.js";
export * from "./EnvironmentBackgroundMedia.js";
export * from "./LandingStage.js";