Extract shared home and environment settings for product reuse
This commit is contained in:
@@ -82,6 +82,7 @@ import {
|
||||
type StoredLayout,
|
||||
} from "./designProfile.js";
|
||||
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
|
||||
import { EnvironmentCatalogDemo } from "./EnvironmentCatalogDemo.js";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
@@ -1427,6 +1428,9 @@ export function CatalogApp() {
|
||||
<SettingsCard title="Устройства не обнаружены" description="Подключите устройство, чтобы оно появилось в списке." />
|
||||
<SettingsCard align="center" role="status" title="Настройки применены" description="Проверьте подключение устройства." />
|
||||
</Preview>
|
||||
<Preview title="Главная и окружение" note="единые компоненты · сохранение в памяти каталога">
|
||||
<EnvironmentCatalogDemo />
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||
<div className="catalog-window-actions-demo">
|
||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { EnvironmentSettings } from "@nodedc/ui-core";
|
||||
import { Button, EnvironmentSettingsWindow, LandingStage, UserProfileMenu } from "@nodedc/ui-react";
|
||||
|
||||
const initial: EnvironmentSettings = { revision: 0, pages: { home: {
|
||||
headerLabel: "Продукт", eyebrow: "NODE.DC / ГЛАВНАЯ", title: "Главная продукта",
|
||||
description: "Общая композиция страницы и настроек окружения.", primaryWorkspaceId: null, secondaryWorkspaceId: null,
|
||||
background: { enabled: false, imageDurationSeconds: 10, items: [] },
|
||||
} } };
|
||||
|
||||
export function EnvironmentCatalogDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings, setSettings] = useState(initial);
|
||||
const urls = useRef<string[]>([]);
|
||||
useEffect(() => () => urls.current.forEach(url => URL.revokeObjectURL(url)), []);
|
||||
return <>
|
||||
<div className="catalog-environment-actions">
|
||||
<UserProfileMenu displayName="DC" subtitle="Каталог компонентов" triggerLabel={null}
|
||||
actions={[{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setOpen(true) }]} />
|
||||
<Button variant="primary" tone="neutral" onClick={() => setOpen(true)}>Настройки окружения</Button>
|
||||
<Button variant="primary" tone="neutral" disabled>Неактивное действие</Button>
|
||||
</div>
|
||||
<div className="catalog-environment-stage"><LandingStage page={settings.pages.home} /></div>
|
||||
<EnvironmentSettingsWindow open={open} onClose={() => setOpen(false)} productName="Продукт"
|
||||
surfaces={[{ id: "home", home: true, description: "Главная страница продукта", actions: [] }]}
|
||||
settings={settings} state="ready" error={null}
|
||||
onSave={async draft => { const next = { ...draft, revision: draft.revision + 1 }; setSettings(next); return next; }}
|
||||
onUpload={async (_surface, _item, file) => {
|
||||
const url = URL.createObjectURL(file); urls.current.push(url);
|
||||
return { url, fileName: file.name, mediaKind: file.type.startsWith("video/") ? "video" : "image" };
|
||||
}} />
|
||||
</>;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
.catalog-environment-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.catalog-environment-stage { display: grid; width: 100%; min-height: 480px; }
|
||||
|
||||
:root {
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
@@ -74,3 +74,12 @@
|
||||
- не поддерживать отдельную тему путём fork компонента;
|
||||
- не использовать legacy Engine inspector как промежуточный канон;
|
||||
- не удалять production local component до проверки package replacement.
|
||||
# Mission Core and onboard Home (R17)
|
||||
|
||||
Mission Core and Mission Core Node consume `EnvironmentSettingsWindow`,
|
||||
`EnvironmentMediaPlaylistEditor`, `EnvironmentBackgroundMedia` and `LandingStage`
|
||||
from `@nodedc/ui-react`. The former Control Station files are thin API/product
|
||||
registry adapters or compatibility re-exports. They own no copied form markup or
|
||||
presentation CSS. Node supplies Home only; Core retains all existing pages and
|
||||
its wire/storage schema. Both use the existing `UserProfileMenu` avatar dropdown.
|
||||
The component contract and catalog example are in `ENVIRONMENT_SETTINGS.md`.
|
||||
|
||||
@@ -341,3 +341,13 @@ Engine продолжает владеть определениями полей
|
||||
|
||||
StatusBadge `variant="indicator"` отображает только одну лампу состояния.
|
||||
Передавайте `aria-label` и `title`; дополнительный текст и значки скрываются.
|
||||
|
||||
## EnvironmentSettingsWindow и EnvironmentMediaPlaylistEditor
|
||||
|
||||
Единый редактор окружения перенесён из принятой композиции Mission Core. Принимает страницы, quick-action варианты, документ, состояние, upload/save adapters и название продукта. Использует тот же FeatureSettingsWindow, поля, переключатели, медиаконтент и перестановку; новое приложение передаёт данные, а не копирует форму. Для бортового приложения передаётся только home. Контракты и пример: [ENVIRONMENT_SETTINGS.md](ENVIRONMENT_SETTINGS.md).
|
||||
|
||||
## LandingStage и EnvironmentBackgroundMedia
|
||||
|
||||
Общая главная: надзаголовок, заголовок, описание, быстрые действия, фон и необязательные status/footer slots. Изображения меняются по таймеру, видео — после завершения; ошибочные источники пропускаются. Пустой фон однотонный, без декоративного круга. Состояние страниц и хранение принадлежат приложению.
|
||||
|
||||
Для primary Button `tone="neutral"` фиксирует белый enabled и серый disabled, независимо от темы и акцента; keyboard focus остаётся видимым. MediaSourceField принимает `disabled` и блокирует все операции выбора источника, пока родитель сохраняет документ или загружает файл.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Shared environment and home
|
||||
|
||||
The owner-approved Mission Core settings anatomy is now canonical, not a screenshot to reproduce. Consumers use UserProfileMenu for the avatar dropdown, EnvironmentSettingsWindow for the settings composition, EnvironmentMediaPlaylistEditor for the list and LandingStage/EnvironmentBackgroundMedia for the home. All geometry and responsive styles live in ui-core.
|
||||
|
||||
The neutral primary Button tone comes from shared tokens, stays white when enabled and gray when disabled across dark/light and accent changes. It does not alter other applications' default primary theme.
|
||||
|
||||
## Consumer contract
|
||||
|
||||
Pass productName, a nonempty stable surfaces list, optional initialSurfaceId, a revision/pages document and save/upload adapters. Each surface references one present page and declares its available quick actions. Workspace IDs are opaque application-owned identifiers. The library owns only draft/form/list/playback behavior; it never imports Mission Core's registry or calls product APIs. Shared data shapes/list functions are exported by ui-core so nonvisual application contracts do not depend on React.
|
||||
|
||||
Settings uses the existing FeatureSettingsWindow/Window focus and Escape behavior. A busy parent disables editing and save; failed saves retain the draft. Each playlist has up to 24 items. Images use the configured delay; a video advances on ended, with one video looping. Failed media is skipped. List display reverses playback order to keep newly added media at the top, as in the accepted source UI.
|
||||
|
||||
The application validates URLs/media bytes, persists uploads and document revisions, handles authentication and rejects stale saves. An upload response provides URL, fileName and mediaKind. A file URL belongs to the storage adapter; an external source must use HTTP(S). No media defaults to an invented graphic. The product may supply status/footer slots without forking the stage.
|
||||
|
||||
## Adoption and validation
|
||||
|
||||
Mission Core passes all existing page definitions and its current operator-environment API. Mission Core Node passes home only and authenticated local presentation storage. Device control never depends on this document. The catalog EnvironmentCatalogDemo exercises the same exported components with an explicitly in-memory save/upload adapter; it is not a persistence service.
|
||||
|
||||
Validate the source migration, TypeScript builds, playlist functions, neutral enabled/disabled DOM contract, dark/light and multiple accents, modal Escape, media order/timing and both consumer persistence adapters. Long-running or physical device acceptance remains application-owned.
|
||||
+2
-1
@@ -11,10 +11,11 @@
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:control-density && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:control-density && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile && npm run test:environment-settings",
|
||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
"test:environment-settings": "node --test scripts/environment-settings.test.mjs",
|
||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
||||
"test:data-product-consumer": "node --test server/foundry-data-product-consumer.test.mjs",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })) },
|
||||
}])) };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from "./floating.js";
|
||||
export * from "./glass.js";
|
||||
export * from "./theme.js";
|
||||
export * from "./toolbar.js";
|
||||
export * from "./environment.js";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
+101
-10
@@ -71,17 +71,38 @@
|
||||
"id": "button",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["Button", "IconButton"],
|
||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||
"exports": [
|
||||
"Button",
|
||||
"IconButton"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-button",
|
||||
"nodedc-icon-button"
|
||||
],
|
||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||
"sizes": ["default", "compact", "dense"],
|
||||
"shapes": ["default", "pill", "rounded"],
|
||||
"variants": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"ghost",
|
||||
"danger",
|
||||
"accent"
|
||||
],
|
||||
"sizes": [
|
||||
"default",
|
||||
"compact",
|
||||
"dense"
|
||||
],
|
||||
"shapes": [
|
||||
"default",
|
||||
"pill",
|
||||
"rounded"
|
||||
],
|
||||
"rules": [
|
||||
"Icon-only actions are circular by default.",
|
||||
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
|
||||
"Filled accent actions derive foreground contrast from the actual accent.",
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message.",
|
||||
"Primary tone=neutral is white when enabled and gray when disabled in every theme/accent; use it for accent-independent operator actions without local CSS overrides."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -424,12 +445,28 @@
|
||||
"id": "media-source-field",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["MediaSourceField"],
|
||||
"exports": [
|
||||
"MediaSourceField"
|
||||
],
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createMediaSourceController"],
|
||||
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"],
|
||||
"domExports": [
|
||||
"createMediaSourceController"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-media-field",
|
||||
"nodedc-media-control",
|
||||
"nodedc-media-source-button",
|
||||
"nodedc-media-preview"
|
||||
],
|
||||
"summary": "Launcher/CMS media control for video, image or logo file/URL sources, filename, preview and adapter-provided storage state.",
|
||||
"behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
|
||||
"behavior": [
|
||||
"controlled file/url source",
|
||||
"native accessible file input",
|
||||
"URL input",
|
||||
"image/video preview",
|
||||
"path, hint and error slots",
|
||||
"controlled disabled state locks file, URL and source switching during a parent save/upload"
|
||||
],
|
||||
"rules": [
|
||||
"The component owns geometry, source switching and accessibility.",
|
||||
"The consumer owns media library, storage upload, validation and the persisted URL.",
|
||||
@@ -510,6 +547,60 @@
|
||||
"The panel variant reuses AdminNavigationPanel pill, icon and theme tokens while retaining the same controlled accordion state and domain-owned content.",
|
||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "environment-settings",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"EnvironmentSettingsWindow",
|
||||
"EnvironmentMediaPlaylistEditor"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-environment-settings",
|
||||
"nodedc-environment-media-playlist"
|
||||
],
|
||||
"summary": "Shared avatar Settings → Environment composition for product/home copy, quick actions and image/video playlists.",
|
||||
"behavior": [
|
||||
"product-configured page list",
|
||||
"draft/reset/save",
|
||||
"upload adapter",
|
||||
"ordered media playlist",
|
||||
"image timing",
|
||||
"parent save/upload disabled state"
|
||||
],
|
||||
"rules": [
|
||||
"Reuse FeatureSettingsWindow and existing form primitives; never fork an application-specific settings form.",
|
||||
"Consumers own page/action IDs, storage, revision conflicts, authentication and upload URLs.",
|
||||
"A single-home application passes one surface; multi-section applications pass all admitted pages.",
|
||||
"Closing the window preserves host authority and never sends device commands."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "landing-stage",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"LandingStage",
|
||||
"EnvironmentBackgroundMedia"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-landing-stage",
|
||||
"nodedc-landing-stage__media"
|
||||
],
|
||||
"summary": "Shared product landing stage with editable copy, quick actions and ordered image/video background.",
|
||||
"behavior": [
|
||||
"image timer",
|
||||
"video end advance",
|
||||
"skip failed media",
|
||||
"flat empty background",
|
||||
"product status/footer slots"
|
||||
],
|
||||
"rules": [
|
||||
"No decorative radial gradient or invented graphic appears without configured media.",
|
||||
"Applications supply content and actions, not duplicate stage markup.",
|
||||
"Background playback and scene navigation never own device acquisition."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"moduleFoundry": "../docs/MODULE_STUDIO.md",
|
||||
"mapTemplate": "../docs/MAP_TEMPLATE.md",
|
||||
"icons": "../docs/ICONS.md",
|
||||
"consumption": "../docs/CONSUMPTION.md"
|
||||
"consumption": "../docs/CONSUMPTION.md",
|
||||
"environmentSettings": "../docs/ENVIRONMENT_SETTINGS.md"
|
||||
},
|
||||
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
|
||||
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { appendEnvironmentMediaItem, removeEnvironmentMediaItem, cloneEnvironmentSettings } from "../packages/ui-core/dist/index.js";
|
||||
import { LandingStage, Button, MediaSourceField } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
const image = { id: "media-first", source: "url", url: "https://example.invalid/background.png", mediaKind: "image", fileName: null };
|
||||
const video = { ...image, id: "media-second", url: "https://example.invalid/background.mp4", mediaKind: "video" };
|
||||
const page = { headerLabel: "Product", eyebrow: "Board", title: "Home", description: "Local operator home", primaryWorkspaceId: null, secondaryWorkspaceId: null, background: { enabled: false, imageDurationSeconds: 10, items: [] } };
|
||||
|
||||
test("media edits preserve playback order and leave saved settings untouched", () => {
|
||||
const settings = { revision: 9, pages: { home: { ...page, background: appendEnvironmentMediaItem(page.background, image) } } };
|
||||
const draft = cloneEnvironmentSettings(settings);
|
||||
draft.pages.home.background = appendEnvironmentMediaItem(draft.pages.home.background, video);
|
||||
draft.pages.home.background.items[0].url = "https://example.invalid/edited.png";
|
||||
assert.equal(settings.pages.home.background.items[0].url, image.url);
|
||||
assert.deepEqual(draft.pages.home.background.items.map(item => item.id), [image.id, video.id]);
|
||||
const removed = removeEnvironmentMediaItem(draft.pages.home.background, image.id);
|
||||
assert.deepEqual(removed.items, [video]);
|
||||
assert.equal(removeEnvironmentMediaItem(removed, video.id).enabled, false);
|
||||
assert.equal(appendEnvironmentMediaItem(removed, video), removed);
|
||||
});
|
||||
|
||||
test("shared home shows real configured media, and an empty home creates no media", () => {
|
||||
const empty = renderToStaticMarkup(createElement(LandingStage, { page }));
|
||||
assert.match(empty, /<h1>Home<\/h1>/);
|
||||
assert.doesNotMatch(empty, /<(?:video|img)\b/);
|
||||
const configured = { ...page, background: { ...page.background, enabled: true, items: [video] } };
|
||||
const html = renderToStaticMarkup(createElement(LandingStage, { page: configured }));
|
||||
assert.match(html, /<video[^>]+src="https:\/\/example.invalid\/background.mp4"/);
|
||||
assert.match(html, /loop=""/);
|
||||
assert.match(html, /muted=""/);
|
||||
});
|
||||
|
||||
test("neutral primary retains native disabled semantics and theme default remains opt-in", () => {
|
||||
const disabled = renderToStaticMarkup(createElement(Button, { variant: "primary", tone: "neutral", disabled: true }, "Start"));
|
||||
assert.match(disabled, /data-tone="neutral"/);
|
||||
assert.match(disabled, /disabled=""/);
|
||||
const themed = renderToStaticMarkup(createElement(Button, { variant: "primary" }, "Start"));
|
||||
assert.doesNotMatch(themed, /data-tone/);
|
||||
const field = renderToStaticMarkup(createElement(MediaSourceField, { source: "url", url: "https://example.invalid/a.png", disabled: true, onSourceChange() {}, onUrlChange() {}, onFileChange() {} }));
|
||||
assert.match(field, /<input[^>]*disabled=""/);
|
||||
});
|
||||
Reference in New Issue
Block a user