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
+8
View File
@@ -1,3 +1,11 @@
:root {
--nodedc-neutral-action-bg: rgba(247, 248, 244, 0.96);
--nodedc-neutral-action-hover: #fff;
--nodedc-neutral-action-color: rgba(8, 8, 10, 0.96);
--nodedc-neutral-action-disabled-bg: #757575;
--nodedc-neutral-action-disabled-color: #242424;
}
:root,
[data-nodedc-theme="dark"] {
color-scheme: dark;
+84
View File
@@ -0,0 +1,84 @@
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
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;
title: string;
description: string;
primaryWorkspaceId: string | null;
secondaryWorkspaceId: string | null;
background: EnvironmentBackground;
}
export interface EnvironmentSettings { revision: number; pages: Record<string, EnvironmentPage> }
export interface UploadedEnvironmentMedia { url: string; fileName: string; mediaKind: EnvironmentMediaKind }
export interface EnvironmentAction { id: string; label: string; description?: string }
export interface EnvironmentSurface { id: string; home?: boolean; description?: string; actions: readonly EnvironmentAction[] }
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
fileName: null,
};
}
export function appendEnvironmentMediaItem(
background: EnvironmentBackground,
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
): EnvironmentBackground {
if (
background.items.length >= maxEnvironmentMediaItems
|| background.items.some((candidate) => candidate.id === item.id)
) {
return background;
}
return {
...background,
enabled: background.items.length === 0 ? true : background.enabled,
items: [...background.items, item],
};
}
export function removeEnvironmentMediaItem(
background: EnvironmentBackground,
itemId: string,
): EnvironmentBackground {
const items = background.items.filter((item) => item.id !== itemId);
if (items.length === background.items.length) return background;
return {
...background,
enabled: items.length > 0 && background.enabled,
items,
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
export function cloneEnvironmentSettings(settings: EnvironmentSettings): EnvironmentSettings {
return { ...settings, pages: Object.fromEntries(Object.entries(settings.pages).map(([id, page]) => [id, {
...page, background: { ...page.background, items: page.background.items.map(item => ({ ...item })) },
}])) };
}
+1
View File
@@ -3,3 +3,4 @@ export * from "./floating.js";
export * from "./glass.js";
export * from "./theme.js";
export * from "./toolbar.js";
export * from "./environment.js";
+348
View File
@@ -185,6 +185,26 @@
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 84%, white);
}
.nodedc-button[data-variant="primary"][data-tone="neutral"] {
--nodedc-button-bg: var(--nodedc-neutral-action-bg);
--nodedc-button-color: var(--nodedc-neutral-action-color);
box-shadow: none;
}
.nodedc-button[data-variant="primary"][data-tone="neutral"]:hover:not(:disabled) {
background: var(--nodedc-neutral-action-hover);
}
.nodedc-button[data-variant="primary"][data-tone="neutral"]:disabled {
background: var(--nodedc-neutral-action-disabled-bg);
color: var(--nodedc-neutral-action-disabled-color);
opacity: 1;
}
.nodedc-button[data-variant="primary"][data-tone="neutral"]:focus-visible {
box-shadow: inset 0 0 0 2px var(--nodedc-neutral-action-color);
}
.nodedc-button[data-variant="ghost"] {
background: transparent;
box-shadow: none;
@@ -3850,3 +3870,331 @@ textarea.nodedc-field__control {
.nodedc-status[data-variant="indicator"][data-tone="warning"]::before { background: rgb(var(--nodedc-warning-rgb)); }
.nodedc-status[data-variant="indicator"][data-tone="danger"]::before { background: rgb(var(--nodedc-danger-rgb)); }
.nodedc-status[data-variant="indicator"][data-tone="accent"]::before { background: rgb(var(--nodedc-accent-rgb)); }
/* Shared product landing and environment settings. */
.nodedc-landing-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-canvas-soft);
}
.nodedc-landing-stage__media,
.nodedc-landing-stage__shade {
position: absolute;
inset: 0;
}
.nodedc-landing-stage__media img,
.nodedc-landing-stage__media video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.nodedc-landing-stage__shade {
z-index: 1;
background:
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
pointer-events: none;
}
.nodedc-landing-stage:not([data-has-media="true"]) .nodedc-landing-stage__shade {
background: none;
}
.nodedc-landing-stage__copy {
position: absolute;
z-index: 2;
top: 50%;
left: clamp(2rem, 5vw, 6rem);
width: min(39rem, 50%);
transform: translateY(-55%);
}
.nodedc-landing-stage__copy h1 {
margin: 0.75rem 0 1rem;
color: var(--nodedc-text-primary);
font-size: clamp(3rem, 7vw, 7.6rem);
font-weight: 600;
letter-spacing: -0.072em;
line-height: 0.87;
}
.nodedc-landing-stage__copy p {
max-width: 34rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: clamp(0.8rem, 1vw, 1rem);
line-height: 1.6;
}
.nodedc-landing-stage__actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1.6rem;
}
.nodedc-landing-stage__status {
position: absolute;
z-index: 2;
top: 2.4rem;
right: 2.4rem;
display: grid;
width: min(24rem, 30vw);
gap: 0.6rem;
border-radius: 1.25rem;
background: rgb(10 10 12 / 0.58);
padding: 1rem;
backdrop-filter: blur(18px);
}
.nodedc-landing-stage__status > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.nodedc-landing-stage__status p {
margin: 0.15rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.45;
}
.nodedc-landing-stage__footer {
position: absolute;
z-index: 2;
right: 2.4rem;
bottom: 1.8rem;
left: 2.4rem;
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
font-weight: 780;
letter-spacing: 0.12em;
}
.nodedc-environment-settings {
display: grid;
gap: 1rem;
}
.nodedc-environment-settings__copy {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.nodedc-environment-settings__editor {
display: grid;
gap: 1rem;
}
.nodedc-environment-settings__surface {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
align-items: center;
gap: 1rem;
}
.nodedc-environment-settings__surface > span {
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
font-weight: 650;
}
.nodedc-environment-settings__surface .nodedc-select-anchor,
.nodedc-environment-settings__surface .nodedc-select {
width: 100%;
}
.nodedc-environment-settings__quick-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.nodedc-environment-settings__quick-actions > div {
display: grid;
gap: 0.4rem;
}
.nodedc-environment-settings__quick-actions span {
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
font-weight: 650;
}
.nodedc-environment-settings__quick-actions .nodedc-select-anchor,
.nodedc-environment-settings__quick-actions .nodedc-select {
width: 100%;
}
.nodedc-environment-media-playlist {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.nodedc-environment-media-playlist__head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.nodedc-environment-media-playlist__head > div {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.nodedc-environment-media-playlist__head span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.nodedc-environment-media-playlist__head p,
.nodedc-environment-media-playlist__empty,
.nodedc-environment-media-playlist__timing > span,
.nodedc-environment-media-playlist__error {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.nodedc-environment-media-playlist__error {
color: rgb(var(--nodedc-danger-rgb));
}
.nodedc-environment-media-playlist__items {
display: grid;
min-width: 0;
gap: 0.7rem;
}
.nodedc-environment-media-playlist__item {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.4rem;
}
.nodedc-environment-media-playlist__item-actions {
display: flex;
align-items: center;
gap: 0.1rem;
padding-top: 1.65rem;
}
.nodedc-environment-media-playlist__timing {
display: grid;
gap: 0.42rem;
}
@media (max-width: 760px) {
.nodedc-environment-settings__copy,
.nodedc-environment-settings__quick-actions {
grid-template-columns: 1fr;
}
.nodedc-environment-settings__surface {
grid-template-columns: 1fr;
}
.nodedc-environment-media-playlist__item {
grid-template-columns: minmax(0, 1fr);
}
.nodedc-environment-media-playlist__item-actions {
justify-self: end;
padding-top: 0;
}
}
@media (max-width: 1480px) {
.nodedc-landing-stage__copy {
width: min(35rem, 54%);
}
}
@media (max-width: 1040px) {
.nodedc-landing-stage__status {
width: 20rem;
max-width: 36vw;
}
}
@media (max-width: 1040px) {
.nodedc-landing-stage__copy h1 {
font-size: clamp(2.7rem, 7vw, 5.6rem);
}
}
@media (max-width: 760px) {
.nodedc-landing-stage__copy {
top: 42%;
right: 1.2rem;
left: 1.2rem;
width: auto;
}
}
@media (max-width: 760px) {
.nodedc-landing-stage__copy h1 {
font-size: clamp(2.8rem, 14vw, 5rem);
}
}
@media (max-width: 760px) {
.nodedc-landing-stage__status {
top: auto;
right: 1rem;
bottom: 3.3rem;
left: 1rem;
width: auto;
max-width: none;
}
}
@media (max-width: 760px) {
.nodedc-landing-stage__footer {
right: 1rem;
bottom: 1.1rem;
left: 1rem;
}
}
@media (max-width: 760px) {
.nodedc-landing-stage__footer span:first-child {
display: none;
}
}
@media (max-height: 720px) and (min-width: 761px) {
.nodedc-landing-stage__copy h1 {
font-size: clamp(2.7rem, 6vw, 5rem);
}
}
@media (max-height: 720px) and (min-width: 761px) {
.nodedc-landing-stage__copy p {
font-size: 0.76rem;
}
}
@media (max-height: 720px) and (min-width: 761px) {
.nodedc-landing-stage__actions {
margin-top: 1rem;
}
}
@media (max-height: 720px) and (min-width: 761px) {
.nodedc-landing-stage__status {
top: 1.2rem;
right: 1.2rem;
}
}
.nodedc-landing-stage__eyebrow { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); font-weight: var(--nodedc-font-weight-strong); letter-spacing: .12em; }
+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";