diff --git a/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx b/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx index 69b2eb5..9ae85e2 100644 --- a/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx +++ b/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx @@ -1,92 +1 @@ -import { useEffect, useMemo, useState } from "react"; - -import type { - EnvironmentBackground, - EnvironmentMediaItem, -} from "../core/environment/environmentSettings"; - -type ReadyEnvironmentMediaItem = EnvironmentMediaItem & { - url: string; - mediaKind: "image" | "video"; -}; - -function isReadyMediaItem( - item: EnvironmentMediaItem, -): item is ReadyEnvironmentMediaItem { - return Boolean(item.url && item.mediaKind); -} - -export function EnvironmentBackgroundMedia({ - background, -}: { - background: EnvironmentBackground; -}) { - const items = useMemo( - () => background.items.filter(isReadyMediaItem), - [background.items], - ); - const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|"); - const [activeIndex, setActiveIndex] = useState(0); - const [failedIds, setFailedIds] = useState>(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 ( - - ); -} +export { EnvironmentBackgroundMedia } from "@nodedc/ui-react"; diff --git a/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx b/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx index 70185bf..3b0542a 100644 --- a/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx +++ b/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx @@ -1,248 +1 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Icon, - IconButton, - MediaSourceField, - RangeControl, - SortableList, -} from "@nodedc/ui-react"; - -import { - appendEnvironmentMediaItem, - inferEnvironmentMediaKind, - maxEnvironmentMediaItems, - removeEnvironmentMediaItem, - type EnvironmentBackground, - type EnvironmentMediaItem, - type EnvironmentSurfaceId, - type UploadedEnvironmentMedia, -} from "../core/environment/environmentSettings"; - -interface EnvironmentMediaPlaylistEditorProps { - surfaceId: EnvironmentSurfaceId; - background: EnvironmentBackground; - disabled: boolean; - error: string | null; - onChange: (background: EnvironmentBackground) => void; - onBusyChange: (busy: boolean) => void; - onUpload: ( - surfaceId: EnvironmentSurfaceId, - itemId: string, - file: File, - ) => Promise; -} - -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, -): 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>(new Set()); - const [itemErrors, setItemErrors] = useState>({}); - const backgroundRef = useRef(background); - backgroundRef.current = background; - const displayedItems = useMemo( - () => [...background.items].reverse(), - [background.items], - ); - - useEffect(() => { - onBusyChange(uploadingIds.size > 0); - }, [onBusyChange, uploadingIds.size]); - - useEffect(() => () => onBusyChange(false), [onBusyChange]); - - const setItemError = (itemId: string, message?: string) => { - setItemErrors((current) => { - const next = { ...current }; - if (message) next[itemId] = message; - else delete next[itemId]; - return next; - }); - }; - - const uploadFile = async (itemId: string, file?: File) => { - if (!file) return; - setUploadingIds((current) => new Set(current).add(itemId)); - setItemError(itemId); - try { - const uploaded = await onUpload(surfaceId, itemId, file); - onChange(patchItem(backgroundRef.current, itemId, { - source: "file", - url: uploaded.url, - mediaKind: uploaded.mediaKind, - fileName: uploaded.fileName, - })); - } catch (reason) { - setItemError( - itemId, - reason instanceof Error - ? reason.message - : "Не удалось загрузить медиаконтент.", - ); - } finally { - setUploadingIds((current) => { - const next = new Set(current); - next.delete(itemId); - return next; - }); - } - }; - - return ( -
-
-
- Видео / картинка -

MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.

-
- = maxEnvironmentMediaItems} - onClick={() => onChange(appendEnvironmentMediaItem(background))} - > - - -
- - {displayedItems.length ? ( - item.id} - className="environment-media-playlist__items" - onReorder={(items) => onChange({ - ...background, - items: [...items].reverse(), - })} - > - {(item, { handle }) => { - const playbackIndex = background.items.findIndex( - (candidate) => candidate.id === item.id, - ); - return ( -
- { - 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)} - /> -
- { - setItemError(item.id); - onChange(removeEnvironmentMediaItem(background, item.id)); - }} - > - - - {handle} -
-
- ); - }} -
- ) : ( - <> -

- Добавьте первый файл или прямую ссылку на медиаконтент. -

- {error ? ( -

- {error} -

- ) : null} - - )} - -
- `${value} с`} - onChange={(imageDurationSeconds) => onChange({ - ...background, - imageDurationSeconds, - })} - /> - - Новые элементы появляются сверху. Воспроизведение начинается снизу; - перетаскивание меняет порядок. - -
-
- ); -} +export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-react"; diff --git a/apps/control-station/src/components/EnvironmentSettingsWindow.tsx b/apps/control-station/src/components/EnvironmentSettingsWindow.tsx index 6e700fc..67ce0de 100644 --- a/apps/control-station/src/components/EnvironmentSettingsWindow.tsx +++ b/apps/control-station/src/components/EnvironmentSettingsWindow.tsx @@ -1,358 +1,25 @@ -import { useEffect, useMemo, useState } from "react"; -import { - Button, - FeatureSettingsWindow, - Select, - SettingsCard, - Switch, - TextAreaField, - TextField, - WindowFooterActions, -} from "@nodedc/ui-react"; +import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react"; +import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings"; +import { roots, workspaces, workspacesForRoot } from "../productModel"; -import { - cloneEnvironmentSettings, - type EnvironmentBackground, - type EnvironmentPage, - type EnvironmentSettings, - type EnvironmentSurfaceId, - type UploadedEnvironmentMedia, -} from "../core/environment/environmentSettings"; -import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor"; -import { - roots, - workspaces, - workspacesForRoot, -} from "../productModel"; - -interface EnvironmentSettingsWindowProps { +interface Props { open: boolean; settings: EnvironmentSettings; state: "loading" | "ready" | "saving" | "error"; error: string | null; onClose: () => void; onSave: (settings: EnvironmentSettings) => Promise; - onUpload: ( - surfaceId: EnvironmentSurfaceId, - itemId: string, - file: File, - ) => Promise; + onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise; } - -function patchPage( - draft: EnvironmentSettings, - surfaceId: EnvironmentSurfaceId, - patch: Partial, -): EnvironmentSettings { - return { - ...draft, - pages: { - ...draft.pages, - [surfaceId]: { - ...draft.pages[surfaceId], - ...patch, - }, - }, - }; -} - -function patchBackground( - draft: EnvironmentSettings, - surfaceId: EnvironmentSurfaceId, - patch: Partial, -): EnvironmentSettings { - const page = draft.pages[surfaceId]; - return patchPage(draft, surfaceId, { - background: { - ...page.background, - ...patch, - }, - }); -} - -export function EnvironmentSettingsWindow({ - open, - settings, - state, - error, - onClose, - onSave, - onUpload, -}: EnvironmentSettingsWindowProps) { - const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings)); - const [surfaceId, setSurfaceId] = useState("fleet"); - const [uploading, setUploading] = useState(false); - const [localError, setLocalError] = useState(null); - - useEffect(() => { - if (!open) return; - setDraft(cloneEnvironmentSettings(settings)); - setLocalError(null); - }, [open, settings]); - - const selectedPage = draft.pages[surfaceId]; - const selectedBackground = selectedPage.background; - const pageOptions = useMemo(() => [ - { - value: "home" as const, - label: draft.pages.home.headerLabel, - description: "Главная страница продукта", - }, - ...roots.map((root) => ({ - value: root.id, - label: draft.pages[root.id].headerLabel, - description: `Стартовая страница раздела «${draft.pages[root.id].title}»`, - })), - ], [draft.pages]); - const quickActionWorkspaces = useMemo( - () => surfaceId === "home" - ? workspaces.filter((workspace) => !workspace.internalOnly) - : workspacesForRoot(surfaceId), - [surfaceId], - ); - 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 === "saving" || uploading; - - const updatePage = (patch: Partial) => { - 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; - } - if ( - selectedPage.primaryWorkspaceId - && selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId - ) { - setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности."); - return; - } - const invalidMedia = Object.entries(draft.pages).find(([, page]) => ( - (page.background.enabled && !page.background.items.length) - || page.background.items.some((item) => { - if (!item.url || !item.mediaKind) return true; - if (item.source !== "url") return false; - try { - const parsed = new URL(item.url); - return !["http:", "https:"].includes(parsed.protocol); - } catch { - return true; - } - }) - )); - if (invalidMedia) { - setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId); - setLocalError( - "Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.", - ); - return; - } - setLocalError(null); - try { - await onSave({ - ...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 ( - undefined} - onClose={onClose} - footer={( - - - - - )} - > -
- { - if (enabled && !selectedBackground.items.length) { - setLocalError("Сначала добавьте медиаконтент."); - return; - } - setLocalError(null); - setDraft((current) => - patchBackground(current, surfaceId, { enabled })); - }} - /> - )} - > -
-
- Страница - updatePage({ - primaryWorkspaceId: value === "none" ? null : value, - })} - /> -
-
- Кнопка 2 -