import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core"; import { AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, Checker, ColorField, ConfirmationModal, ControlRow, Dropdown, FieldFrame, GlassSurface, HeaderAvatar, HeaderNavigation, HeaderProfile, HeaderProfileButton, HeaderWorkspace, Icon, IconButton, Inspector, MediaSourceField, RangeControl, Select, ShareAccessModal, ShareLinkModal, SettingsCard, StatusBadge, Switch, TextAreaField, TextField, useApplicationWorkspace, Window, WindowFooterActions, type IconName, type ShareAccessMember, } from "@nodedc/ui-react"; type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "icons"; const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [ { label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" }, { label: "Rose", value: [255, 62, 108], hex: "#ff3e6c" }, { label: "Blush", value: [255, 108, 175], hex: "#ff6caf" }, { label: "Magenta", value: [215, 70, 255], hex: "#d746ff" }, ]; type MaterialDraft = { panelHex: string; panelOpacity: number; fieldHex: string; fieldOpacity: number; }; const materialDefaults: Record = { dark: { panelHex: "#151517", panelOpacity: 100, fieldHex: "#2a2a2c", fieldOpacity: 100, }, light: { panelHex: "#ffffff", panelOpacity: 100, fieldHex: "#ffffff", fieldOpacity: 100, }, }; type ShareRole = "viewer" | "editor" | "admin"; const shareRoleOptions = [ { value: "viewer", label: "Просмотр" }, { value: "editor", label: "Редактор" }, { value: "admin", label: "Соавтор" }, ] satisfies Array<{ value: ShareRole; label: string }>; const selectOptions = [ { value: "active", label: "Активен", description: "Сервис доступен пользователям" }, { value: "maintenance", label: "Техработы", description: "Временно недоступен" }, { value: "disabled", label: "Отключён", description: "Запуск запрещён" }, ] as const; const sectionDefinitions: Record = { foundation: { eyebrow: "01 / FOUNDATION", title: "Основа", description: "Тема, нейтральные поверхности и неизменяемая геометрия приложения.", icon: "panel", }, controls: { eyebrow: "02 / CONTROLS", title: "Контролы", description: "Кнопки, поля, выбор, Environment Settings и сворачиваемые секции нового Engine.", icon: "sliders", }, media: { eyebrow: "03 / MEDIA & SETTINGS", title: "Медиа и настройки", description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.", icon: "image", }, windows: { eyebrow: "04 / WINDOWS", title: "Окна", description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.", icon: "apps", }, modals: { eyebrow: "05 / MODALS", title: "Модалки", description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.", icon: "clipboard", }, icons: { eyebrow: "06 / ICONS", title: "Иконки", description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.", icon: "grid", }, }; const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [ { title: "Окно и слой", note: "Launcher / Hub", icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"], }, { title: "Навигация", note: "Launcher / Engine / BIM", icons: ["chevron-left", "chevron-right", "chevron-down", "grid", "list", "sliders", "search"], }, { title: "Редактирование", note: "Launcher / SEO", icons: ["save", "edit", "trash", "copy", "upload", "download", "external"], }, { title: "Состояние и доступ", note: "Вся платформа", icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"], }, { title: "Сущности", note: "Общий словарь", icons: ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"], }, { title: "Контент", note: "SEO / BIM / Engine", icons: ["image", "video", "file", "folder", "clipboard", "settings"], }, ]; const iconLabels: Record = { activity: "Активность", alert: "Предупреждение", apps: "Приложения", building: "Компания", check: "Готово", "chevron-down": "Раскрыть", "chevron-left": "Назад", "chevron-right": "Вперёд", circle: "Маркер", clipboard: "Список", close: "Закрыть", copy: "Копировать", database: "База данных", download: "Скачать", edit: "Редактировать", expand: "Развернуть", external: "Открыть снаружи", file: "Файл", folder: "Папка", globe: "Публичный контур", grid: "Обзор", image: "Изображение", inbox: "Уведомления", key: "Ключ", list: "Список задач", lock: "Закрыто", mail: "Приглашение", minimize: "Свернуть", network: "Связи", panel: "Панель", plus: "Добавить", profile: "Профиль", refresh: "Обновить", save: "Сохранить", search: "Поиск", settings: "Настройки", shield: "Доступ", sliders: "Параметры", trash: "Удалить", upload: "Загрузить", users: "Участники", video: "Видео", }; function rgbFromHex(hex: string): RgbTuple | null { const normalized = hex.trim().replace(/^#/, ""); if (!/^[0-9a-f]{6}$/i.test(normalized)) return null; return [ Number.parseInt(normalized.slice(0, 2), 16), Number.parseInt(normalized.slice(2, 4), 16), Number.parseInt(normalized.slice(4, 6), 16), ]; } function Preview({ title, note, children, className }: { title: string; note?: string; children: ReactNode; className?: string; }) { return (
{title} {note ? {note} : null}
{children}
); } export function CatalogApp() { const [theme, setTheme] = useState("dark"); const [accentHex, setAccentHex] = useState("#ff2f92"); const [materialByTheme, setMaterialByTheme] = useState>(() => ({ dark: { ...materialDefaults.dark }, light: { ...materialDefaults.light }, })); const [environmentLayoutSaved, setEnvironmentLayoutSaved] = useState(false); const workspace = useApplicationWorkspace(); const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace; const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active"); const [checker, setChecker] = useState(true); const [brightness, setBrightness] = useState(49); const [glowDistance, setGlowDistance] = useState(105); const [usePortColors, setUsePortColors] = useState(false); const [connectionType, setConnectionType] = useState("spline"); const [lightColor, setLightColor] = useState("#ff2f92"); const [connectionColor, setConnectionColor] = useState("#404040"); const [fillColor, setFillColor] = useState("#ff6caf"); const [fillOpacity, setFillOpacity] = useState(100); const [strokeColor, setStrokeColor] = useState("#2b2b36"); const [strokeOpacity, setStrokeOpacity] = useState(100); const [windowOpen, setWindowOpen] = useState(false); const [sidePanelOpen, setSidePanelOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [shareAccessOpen, setShareAccessOpen] = useState(false); const [shareLinkOpen, setShareLinkOpen] = useState(false); const [contextActionOpen, setContextActionOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [detailExpanded, setDetailExpanded] = useState(false); const [projectName, setProjectName] = useState("NODE.DC Platform"); const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения."); const [mediaSource, setMediaSource] = useState<"file" | "url">("file"); const [mediaUrl, setMediaUrl] = useState(""); const [mediaFileName, setMediaFileName] = useState("launcher-stage.mp4"); const [fileMediaSrc, setFileMediaSrc] = useState("/launcher-stage.mp4"); const [mediaError, setMediaError] = useState(""); const [mediaVisible, setMediaVisible] = useState(true); const mediaObjectUrlRef = useRef(null); const [shareEmail, setShareEmail] = useState(""); const [shareRole, setShareRole] = useState("editor"); const [shareMessage, setShareMessage] = useState(""); const [shareMembers, setShareMembers] = useState>>([ { id: "owner", name: "DC Constructions", email: "dc@nodedc.ru", role: "admin", roleLabel: "Владелец", immutable: true }, { id: "editor", name: "Maria Petrova", email: "maria@nodedc.ru", role: "editor", roleLabel: "Редактор" }, ]); const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl) ? mediaUrl : null; const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc; const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]); const material = materialByTheme[theme]; const panelMaterial = useMemo(() => rgbFromHex(material.panelHex) ?? rgbFromHex(materialDefaults[theme].panelHex)!, [material.panelHex, theme]); const fieldMaterial = useMemo(() => rgbFromHex(material.fieldHex) ?? rgbFromHex(materialDefaults[theme].fieldHex)!, [material.fieldHex, theme]); useEffect(() => { applyNodedcTheme(document.documentElement, { theme, accent, material: { panel: panelMaterial, panelOpacity: material.panelOpacity / 100, field: fieldMaterial, fieldOpacity: material.fieldOpacity / 100, }, }); }, [accent, fieldMaterial, material.fieldOpacity, material.panelOpacity, panelMaterial, theme]); useEffect(() => () => { if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); }, []); const handleStageMediaFile = (file?: File) => { if (!file) return; const isMp4 = file.type === "video/mp4" || /\.mp4$/i.test(file.name); if (!isMp4) { setMediaError("Для заставки нужен MP4-файл."); return; } if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); const objectUrl = URL.createObjectURL(file); mediaObjectUrlRef.current = objectUrl; setFileMediaSrc(objectUrl); setMediaFileName(file.name); setMediaSource("file"); setMediaVisible(true); setMediaError(""); }; const inviteShareMember = () => { const email = shareEmail.trim().toLowerCase(); if (!email) return; setShareMembers((current) => [ ...current.filter((member) => member.email !== email), { id: email, name: email.split("@")[0], email, role: shareRole, roleLabel: shareRoleOptions.find((item) => item.value === shareRole)?.label }, ]); setShareMessage(`Доступ выдан пользователю ${email}`); setShareEmail(""); }; const openSection = (next: string) => { workspace.openView(next as CatalogSection); }; const closeGuideline = workspace.closeNavigation; const updateMaterial = (changes: Partial) => { setMaterialByTheme((current) => ({ ...current, [theme]: { ...current[theme], ...changes }, })); }; const saveEnvironmentLayout = () => { const draft = { theme, accentHex, material, environment: { lightColor, brightness, glowDistance, connectionType, connectionColor, usePortColors, fillColor, fillOpacity, strokeColor, strokeOpacity, }, }; try { window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft)); } catch { // The preview remains useful in restricted browser contexts even without local persistence. } setEnvironmentLayoutSaved(true); }; const environmentSections = [ { id: "application-material", label: "Материал приложения", description: "live draft", tone: "accent" as const, content: ( <> updateMaterial({ panelHex })} /> `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} /> updateMaterial({ fieldHex })} /> `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} /> ), }, { id: "illumination", label: "Подсветка", tone: "accent" as const, content: ( <> `${value}%`} onChange={setBrightness} /> `${value}%`} onChange={setGlowDistance} /> ), }, { id: "connections", label: "Связи", tone: "accent" as const, content: ( <> setSidePanelOpen(false)} >

Боковая панель демонстрирует только layer-механику: она modeless, не затемняет stage и не забирает фокус. Environment controls живут в разделе «Контролы».

setCreateModalOpen(false)} footer={ <> } >
setProjectName(event.target.value)} /> setNotes(event.target.value)} />