const ADMIN_LAYOUT_STORAGE_KEY = "nodedc-admin-layout-v1"; const KNOWLEDGE_ROOT_ID = "__knowledge-root__"; function readAdminLayoutState() { try { return JSON.parse(localStorage.getItem(ADMIN_LAYOUT_STORAGE_KEY) || "{}"); } catch { return {}; } } function writeAdminLayoutState(nextLayout) { try { localStorage.setItem(ADMIN_LAYOUT_STORAGE_KEY, JSON.stringify(nextLayout)); } catch { // Storage can be unavailable in private or restricted browser contexts. } } function updateAdminLayoutState(patch) { writeAdminLayoutState({ ...readAdminLayoutState(), ...patch, }); } function readCollectionOpenState() { const collections = readAdminLayoutState().collections || {}; return new Map( Object.entries(collections).map(([key, value]) => [key, new Set(Array.isArray(value) ? value : [])]), ); } function readLastMediaDirectory() { return String(readAdminLayoutState().lastMediaDirectory || "").replace(/^\/+/, ""); } function readAdminMode() { const mode = readAdminLayoutState().mode; return mode === "seo" || mode === "knowledge" ? mode : "content"; } const state = { mode: readAdminMode(), page: null, projectConfig: null, projectConfigDirty: false, knowledge: null, knowledgeSelectedId: null, knowledgeSavedSelection: null, knowledgeOpenIds: new Set(readAdminLayoutState().knowledgeOpenIds || []), templates: [], selected: null, clipboard: null, drag: null, dragTarget: null, dirty: false, filePickerActive: false, mediaUploadActive: false, filePickerResetTimer: null, deleteConfirmResolve: null, mediaActionResolve: null, knowledgeButtonResolve: null, collectionOpenState: readCollectionOpenState(), mediaLibrary: { columns: [], context: null, contextMenu: null, trashMode: false, previousPath: "", selectedPath: "", selectedEntry: null, selectedUrl: "", root: "", kind: "all", query: "", loaded: false, dragEntryPath: "", lastPath: readLastMediaDirectory(), }, }; const SECTIONS_REGION_ID = "sections"; const SECTIONS_REGION_LABEL = "Секции"; const PAGE_SEO_DEFAULTS = { title: "NODE.DC - платформа для процессов, данных и ИИ-агентов", description: "NODE.DC объединяет Hub, Engine, Ops, прикладные модули и сквозного ассистента для задач, workflow, данных, ролей, интеграций и инженерных моделей.", canonicalUrl: "https://nodedc.dctouch.ru/", robots: "index,follow,max-image-preview:large", siteName: "NODE.DC", locale: "ru_RU", ogImage: "https://nodedc.dctouch.ru/assets/site/images/69b6acdde3123ef078e2b7f3_og-img.jpg", imageAlt: "NODE.DC - платформа для процессов, данных и ИИ-агентов", organizationName: "DCTOUCH", organizationUrl: "https://dctouch.ru/", applicationCategory: "BusinessApplication", }; const BLOCK_SEO_DEFAULTS = { targetCluster: "", searchIntent: "", primaryQuery: "", secondaryQueries: "", title: "", description: "", coverageNotes: "", indexHint: "section", }; const PAGE_SEO_FIELDS = [ { path: "seo.title", label: "Title страницы", kind: "text", group: "Сниппет", min: 35, max: 70 }, { path: "seo.description", label: "Description страницы", kind: "textarea", group: "Сниппет", min: 110, max: 170 }, { path: "seo.canonicalUrl", label: "Canonical URL", kind: "url", group: "Индексация" }, { path: "seo.robots", label: "Robots meta", kind: "select", group: "Индексация", options: ["index,follow,max-image-preview:large", "noindex,nofollow", "index,follow"], }, { path: "seo.siteName", label: "Site name", kind: "text", group: "Open Graph" }, { path: "seo.locale", label: "Locale", kind: "text", group: "Open Graph" }, { path: "seo.ogImage", label: "OG image", kind: "url", group: "Open Graph" }, { path: "seo.imageAlt", label: "OG image alt", kind: "textarea", group: "Open Graph", min: 20, max: 120 }, { path: "seo.organizationName", label: "Organization", kind: "text", group: "Schema.org" }, { path: "seo.organizationUrl", label: "Organization URL", kind: "url", group: "Schema.org" }, { path: "seo.applicationCategory", label: "Application category", kind: "text", group: "Schema.org" }, ]; const BLOCK_SEO_FIELDS = [ { path: "seo.targetCluster", label: "Кластер", kind: "text", group: "Семантика" }, { path: "seo.searchIntent", label: "Интент", kind: "textarea", group: "Семантика", min: 20, max: 220 }, { path: "seo.primaryQuery", label: "Основной запрос", kind: "text", group: "Семантика" }, { path: "seo.secondaryQueries", label: "Смежные запросы", kind: "textarea", group: "Семантика" }, { path: "seo.title", label: "SEO-заголовок блока", kind: "text", group: "Сниппет блока", min: 35, max: 80 }, { path: "seo.description", label: "SEO-описание блока", kind: "textarea", group: "Сниппет блока", min: 90, max: 180 }, { path: "seo.indexHint", label: "Роль в индексации", kind: "select", group: "Индексация", options: ["section", "supporting", "conversion", "noindex"], }, { path: "seo.coverageNotes", label: "Заметки по охвату", kind: "textarea", group: "Аналитика", min: 20, max: 500 }, ]; const el = { modeContent: document.querySelector("#admin-mode-content"), modeSeo: document.querySelector("#admin-mode-seo"), modeKnowledge: document.querySelector("#admin-mode-knowledge"), modeNav: document.querySelector(".nodedc-expanded-nav-group"), sidebarTitle: document.querySelector("#sidebar-title"), workspaceName: document.querySelector("#workspace-name"), workspaceRole: document.querySelector("#workspace-role"), editorHeading: document.querySelector("#editor-heading"), panelKicker: document.querySelector("#content-panel-kicker"), panelTitle: document.querySelector("#content-panel-title"), panelNote: document.querySelector("#content-panel-note"), regions: document.querySelector("#regions"), addProject: document.querySelector("#add-project"), projectSettings: document.querySelector("#project-settings"), addBlock: document.querySelector("#add-block"), reload: document.querySelector("#reload"), save: document.querySelector("#save"), render: document.querySelector("#render"), dirtyState: document.querySelector("#dirty-state"), status: document.querySelector("#status"), empty: document.querySelector("#empty-state"), form: document.querySelector("#block-form"), knowledgeForm: document.querySelector("#knowledge-form"), projectSettingsForm: document.querySelector("#project-settings-form"), selectedRegion: document.querySelector("#selected-region"), selectedTitle: document.querySelector("#selected-title"), id: document.querySelector("#block-id"), label: document.querySelector("#block-label"), template: document.querySelector("#block-template"), anchor: document.querySelector("#block-anchor"), targetOptions: document.querySelector("#block-target-options"), enabled: document.querySelector("#block-enabled"), contentFields: document.querySelector("#content-fields"), json: document.querySelector("#block-json"), moveUp: document.querySelector("#move-up"), moveDown: document.querySelector("#move-down"), duplicate: document.querySelector("#duplicate"), copy: document.querySelector("#copy"), pasteAfter: document.querySelector("#paste-after"), deleteBlock: document.querySelector("#delete-block"), templateModal: document.querySelector("#template-modal"), templateList: document.querySelector("#template-list"), templateModalClose: document.querySelector("#template-modal-close"), templateModalCancel: document.querySelector("#template-modal-cancel"), deleteModal: document.querySelector("#delete-modal"), deleteModalTitle: document.querySelector("#delete-modal-title"), deleteModalBody: document.querySelector("#delete-modal-body"), deleteModalCancel: document.querySelector("#delete-modal-cancel"), deleteModalConfirm: document.querySelector("#delete-modal-confirm"), mediaModal: document.querySelector("#media-modal"), mediaModalTitle: document.querySelector("#media-modal-title"), mediaModalSubtitle: document.querySelector("#media-modal-subtitle"), mediaSiteSize: document.querySelector("#media-site-size"), mediaModalClose: document.querySelector("#media-modal-close"), mediaModalCancel: document.querySelector("#media-modal-cancel"), mediaTrashToggle: document.querySelector("#media-trash-toggle"), mediaSearch: document.querySelector("#media-search"), mediaKindFilters: document.querySelector("#media-kind-filters"), mediaRootFilters: document.querySelector("#media-root-filters"), mediaUpload: document.querySelector("#media-upload"), mediaRefresh: document.querySelector("#media-refresh"), mediaDropZone: document.querySelector("#media-drop-zone"), mediaBreadcrumbs: document.querySelector("#media-breadcrumbs"), mediaList: document.querySelector("#media-list"), mediaEmpty: document.querySelector("#media-empty"), mediaPreview: document.querySelector("#media-preview"), mediaPreviewName: document.querySelector("#media-preview-name"), mediaPreviewPath: document.querySelector("#media-preview-path"), mediaPreviewMeta: document.querySelector("#media-preview-meta"), mediaPreviewUsage: document.querySelector("#media-preview-usage"), mediaSelect: document.querySelector("#media-select"), mediaDelete: document.querySelector("#media-delete"), mediaContextMenu: document.querySelector("#media-context-menu"), mediaActionModal: document.querySelector("#media-action-modal"), mediaActionTitle: document.querySelector("#media-action-title"), mediaActionBody: document.querySelector("#media-action-body"), mediaActionLabel: document.querySelector("#media-action-label"), mediaActionInput: document.querySelector("#media-action-input"), mediaActionCancel: document.querySelector("#media-action-cancel"), mediaActionConfirm: document.querySelector("#media-action-confirm"), knowledgeButtonModal: document.querySelector("#knowledge-button-modal"), knowledgeButtonClose: document.querySelector("#knowledge-button-close"), knowledgeButtonName: document.querySelector("#knowledge-button-name"), knowledgeButtonLink: document.querySelector("#knowledge-button-link"), knowledgeButtonTarget: document.querySelector("#knowledge-button-target"), knowledgeButtonTargetOptions: document.querySelector("#knowledge-button-target-options"), knowledgeButtonError: document.querySelector("#knowledge-button-error"), knowledgeButtonCancel: document.querySelector("#knowledge-button-cancel"), knowledgeButtonConfirm: document.querySelector("#knowledge-button-confirm"), }; const STATIC_FALLBACK = { id: "static-elements", type: "staticElements", adminLabel: "Статичные элементы", enabled: true, content: { navigation: { logoSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg", logoAlt: "главная", brandLabel: "NODE.DC", links: [ { enabled: true, label: "О продукте", href: "", target: "index.html#platform-composition", linkMode: "target", linkTarget: "" }, { enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" }, { enabled: true, label: "Документация", href: "https://nodedc.dctouch.ru/", target: "", linkMode: "href", linkTarget: "_blank", }, { enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-intro", linkMode: "target", linkTarget: "" }, ], }, notifications: [ { enabled: true, eyebrowHtml: 'ДАЛЕЕ — ИЮЛЬ 2026', eyebrowClass: "red-txt gray", title: "Открытая бета", body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", }, { enabled: true, eyebrowHtml: 'ДАЛЕЕ ИЮЛЬ 2026', eyebrowClass: "red-txt gray", title: "Альфа для Founder-доступа", body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.", }, { enabled: true, eyebrowHtml: "СЕЙЧАС", eyebrowClass: "red-txt", title: "Инвайты в закрытую бету уже отправлены.", body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ", }, ], dock: { items: [ { enabled: true, title: "NODE.DC", href: "javascript:void(0)", linkTarget: "", targetId: "app-data", iconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg", iconAlt: "Иконка NODE.DC", className: "dock-icon nodedc-app", iconClass: "logo", dividerBefore: false, }, { enabled: true, title: "Корзина", href: "javascript:void(0)", linkTarget: "", targetId: "", iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif", iconAlt: "Белая иконка корзины.", className: "dock-icon no-bg", iconClass: "dock-app-img trash", dividerBefore: true, }, ], demoFileLabel: "демо-видео.txt", }, assets: { webglModelSrc: "./assets/custom/icon.glb", logoPng: "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png", logoPngAlt: "Логотип NODE.DC", appIconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg", appIconAlt: "Иконка NODE.DC", appWordmarkSrc: "./assets/site/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc", appWordmarkAlt: "Логотип NODE.DC", faviconSvg: "./assets/uploads/favicon/icon-adaptive.svg", faviconIco: "./assets/uploads/favicon/favicon.ico", faviconAppleTouchIcon: "./assets/uploads/favicon/apple-touch-icon.png", faviconPng192: "./assets/uploads/favicon/icon-192.png", faviconPng512: "./assets/uploads/favicon/icon-512.png", faviconManifest: "./assets/uploads/favicon/manifest.webmanifest.json", }, }, editableFields: [ field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"), field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"), field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"), collection( "content.navigation.links", "Кнопки шапки", "Шапка: ссылки", { enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" }, [ field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"), field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"), ], ), collection( "content.notifications", "Уведомления", "Уведомления", { enabled: true, eyebrowHtml: "СЕЙЧАС", eyebrowClass: "red-txt gray", title: "Новое уведомление", body: "Текст уведомления.", }, [ field("eyebrowHtml", "Дата / статус", "html", "html", "Уведомления"), field("eyebrowClass", "CSS-класс статуса", "text", "attr", "Уведомления"), field("title", "Заголовок", "text", "text", "Уведомления"), field("body", "Текст", "textarea", "text", "Уведомления"), ], ), collection( "content.dock.items", "Иконки dock-панели", "Dock", { enabled: true, title: "Новое приложение", href: "javascript:void(0)", linkTarget: "", targetId: "", iconSrc: "", iconAlt: "", className: "dock-icon nodedc-app", iconClass: "", dividerBefore: false, }, [ field("enabled", "Отображение", "boolean", "boolean", "Dock"), field("title", "Название", "text", "text", "Dock"), field("href", "Ссылка / адрес", "url", "attr", "Dock"), field("linkTarget", "Target ссылки", "text", "attr", "Dock"), field("targetId", "ID окна / приложения", "text", "attr", "Dock"), field("iconSrc", "Иконка", "media", "attr", "Dock"), field("iconAlt", "Alt иконки", "text", "text", "Dock"), field("dividerBefore", "Разделитель слева", "boolean", "boolean", "Dock"), ], ), field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"), field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr", "Модель и логотипы"), field("content.assets.logoPng", "PNG-логотип", "media", "attr", "Модель и логотипы"), field("content.assets.logoPngAlt", "PNG-логотип: alt", "text", "text", "Модель и логотипы"), field("content.assets.appIconSrc", "Иконка приложения", "media", "attr", "Модель и логотипы"), field("content.assets.appIconAlt", "Иконка приложения: alt", "text", "text", "Модель и логотипы"), field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr", "Модель и логотипы"), field("content.assets.appWordmarkAlt", "Wordmark приложения: alt", "text", "text", "Модель и логотипы"), field("content.assets.faviconSvg", "Favicon SVG", "media", "attr", "Модель и логотипы"), field("content.assets.faviconIco", "Favicon ICO", "media", "attr", "Модель и логотипы"), field("content.assets.faviconAppleTouchIcon", "Favicon Apple 180", "media", "attr", "Модель и логотипы"), field("content.assets.faviconPng192", "Favicon PNG 192", "media", "attr", "Модель и логотипы"), field("content.assets.faviconPng512", "Favicon PNG 512", "media", "attr", "Модель и логотипы"), field("content.assets.faviconManifest", "Favicon manifest", "media", "attr", "Модель и логотипы"), ], }; const GROUP_COPY = { "Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.", "Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.", Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.", Dock: "Нижняя системная панель: приложения, корзина, ссылки и подписи файлов.", "Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы, favicon-комплект и alt-тексты.", Заголовок: "Главный заголовок и вводный текст выбранной секции.", "Верхний информационный блок": "Верхняя пара: длинный текст и крупный заголовок над медиаслайдером.", "Нижний информационный блок": "Нижняя зеркальная пара: крупный заголовок и длинный текст под медиаслайдером.", "Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.", "Карточка доступа": "Единый компонент окна заявки: редактируется здесь и повторяется в нижнем блоке запроса DEMO.", Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.", "Юридический блок": "Политики, права и нижний текст формы.", "Видео-окно": "Окно демо-видео и подключенный медиа-файл.", Пункты: "Текстовые пункты внутри секции.", Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.", Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.", "Один клик": "Подблок про быстрое добавление и настройку компонентов.", MCP: "Подблок про MCP-сервер и контекст NODE.DC.", "Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.", Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.", FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.", CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.", "Центральный текст": "Отдельный центрированный текстовый блок: надзаголовок, крупная строка и три подписи.", Тарифы: "Окно тарифной таблицы: планы, цены, кнопки и примечания.", "Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.", "Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.", "Карточки скриншотов": "Скриншоты в виде окон и файлов: каждый элемент можно скрыть, переставить, дублировать или удалить.", "TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.", Футер: "Брендовый блок, копирайт и базовые ассеты футера.", "Навигация футера": "Ссылки навигационной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.", "Контактные ссылки": "Ссылки контактной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.", Политики: "Нижняя строка политик и условия отображения.", Контент: "Остальные typed-поля выбранного блока.", }; const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.ico,.avif,.webp,.webmanifest,.json,.woff,.woff2"; const MEDIA_DRAG_MIME = "application/x-nodedc-media-entry"; const MEDIA_KIND_FILTERS = [ ["all", "Все"], ["image", "Изображения"], ["video", "Видео"], ["model", "3D"], ]; const MEDIA_ROOT_FILTERS = [ ["", "Проект"], ["assets", "Assets"], ["assets/uploads", "Uploads"], ["assets/media", "Media"], ["assets/site/images", "NODE.DC"], ["assets/custom", "Custom"], ]; const FINDER_TRASH_ICON_SVG = ''; const mediaFilePicker = createMediaFilePicker(); const collectionItemRuntimeKeys = new WeakMap(); let collectionItemRuntimeKeyCounter = 0; function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) { return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options }; } function collection(path, label, group, itemTemplate, itemFields) { return { path, label, kind: "collection", renderAs: "collection", group, itemTemplate, itemFields, }; } function setStatus(message) { el.status.textContent = message; } function setDirty(isDirty, message = null) { state.dirty = isDirty; el.dirtyState.dataset.dirty = String(isDirty); el.dirtyState.textContent = isDirty ? "Есть несохранённые изменения" : "Синхронизировано"; if (message) { setStatus(message); } } function markDirty(message = null) { setDirty(true, message); } function createMediaFilePicker() { const input = document.createElement("input"); input.type = "file"; input.accept = MEDIA_ACCEPT; input.className = "admin-file-picker-input"; input.tabIndex = -1; input.setAttribute("aria-hidden", "true"); document.body.append(input); return input; } function setFilePickerActive(active) { state.filePickerActive = active; if (active) { document.body.dataset.filePickerActive = "true"; } else { delete document.body.dataset.filePickerActive; } } function recoverEditorSurface() { if (!activeBlock()) return; el.knowledgeForm.classList.add("hidden"); el.projectSettingsForm.classList.add("hidden"); el.empty.classList.add("hidden"); el.form.classList.remove("hidden"); } function scheduleFilePickerRecovery(delay = 360) { window.clearTimeout(state.filePickerResetTimer); state.filePickerResetTimer = window.setTimeout(() => { if (state.mediaUploadActive) return; setFilePickerActive(false); recoverEditorSurface(); }, delay); } function clone(value) { return JSON.parse(JSON.stringify(value)); } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function getByPath(source, path) { return path.split(".").reduce((value, key) => value?.[key], source); } function setByPath(source, path, nextValue) { const keys = path.split("."); const lastKey = keys.pop(); let target = source; keys.forEach((key, index) => { const nextKey = keys[index + 1] ?? lastKey; if (target[key] == null || typeof target[key] !== "object") { target[key] = /^\d+$/.test(nextKey) ? [] : {}; } target = target[key]; }); target[lastKey] = nextValue; } function isProjectSettingsSelected() { return state.selected?.kind === "project-settings"; } function projectName() { return state.projectConfig?.name || "NODE.DC"; } function projectRole() { return state.projectConfig?.role || "Администратор сайта"; } function siteStatus() { return state.projectConfig?.site || { ready: false, missingFiles: [] }; } function siteReady() { return Boolean(siteStatus().ready); } function applyProjectConfigSurface() { if (el.workspaceName) el.workspaceName.textContent = projectName(); if (el.workspaceRole && !isProjectSettingsSelected()) { el.workspaceRole.textContent = state.mode === "knowledge" ? "Редактор материалов" : state.mode === "seo" ? "SEO-режим сайта" : projectRole(); } } async function loadProjectConfig() { const response = await fetch("/api/project/config"); const payload = await response.json().catch(() => null); if (!response.ok || !payload) { throw new Error(payload?.error || "Не удалось загрузить настройки проекта"); } state.projectConfig = payload; state.projectConfigDirty = false; applyProjectConfigSurface(); return payload; } function projectConfigValue(path) { return getByPath(state.projectConfig || {}, path); } function setProjectConfigValue(path, value) { if (!state.projectConfig) state.projectConfig = {}; setByPath(state.projectConfig, path, value); state.projectConfigDirty = true; } function normalizeProjectInputValue(input) { if (input.type === "checkbox") return input.checked; if (input.type === "number") return Number(input.value || 0); return input.value.trim(); } function syncProjectConfigFromForm() { if (!el.projectSettingsForm || !state.projectConfig) return; for (const input of el.projectSettingsForm.querySelectorAll("[data-project-path]")) { setByPath(state.projectConfig, input.dataset.projectPath, normalizeProjectInputValue(input)); } } async function saveProjectSettings() { syncProjectConfigFromForm(); const passwordEnv = String(state.projectConfig?.deploy?.passwordEnv || "").trim(); if (passwordEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(passwordEnv)) { setByPath(state.projectConfig, "deploy.passwordEnv", "MCHOST_PASSWORD"); renderProjectSettings(); setStatus("FTP-пароль не сохраняем в модель. Поле сброшено к имени переменной MCHOST_PASSWORD."); return; } const response = await fetch("/api/project/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(state.projectConfig), }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.ok) { throw new Error(payload?.error || "Не удалось сохранить настройки проекта"); } state.projectConfig = payload.project; state.projectConfigDirty = false; setDirty(false); applyProjectConfigSurface(); renderProjectSettings(); setStatus("Настройки проекта сохранены."); } async function checkProjectConnection() { const [pageResponse, knowledgeResponse, sizeResponse] = await Promise.all([ fetch("/api/page/home"), fetch("/api/knowledge"), fetch("/api/media/site-size"), ]); if (!pageResponse.ok) throw new Error(`home.json: ${await pageResponse.text()}`); if (!knowledgeResponse.ok) throw new Error(`knowledge.json: ${await knowledgeResponse.text()}`); const size = sizeResponse.ok ? await sizeResponse.json() : null; setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`); } function ensurePageSeo() { if (!state.page) return; state.page.seo = { ...PAGE_SEO_DEFAULTS, ...(state.page.seo || {}), }; } function ensureBlockSeo(block) { if (!block || isStaticSelection()) return; block.seo = { ...BLOCK_SEO_DEFAULTS, ...(block.seo || {}), }; } function ensureSeoModel() { ensurePageSeo(); for (const region of state.page?.regions || []) { for (const block of region.blocks || []) { block.seo = { ...BLOCK_SEO_DEFAULTS, ...(block.seo || {}), }; } } } function setAdminMode(nextMode) { if (!siteReady() && !isProjectSettingsSelected()) { state.mode = "content"; updateAdminLayoutState({ mode: state.mode }); renderSiteUnavailable(); return; } const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content"; state.mode = mode; if (isProjectSettingsSelected()) { state.selected = mode === "knowledge" ? null : { kind: "static" }; } updateAdminLayoutState({ mode }); if (mode === "knowledge" && !state.knowledge) { loadKnowledge().catch((error) => setStatus(error.message)); updateAdminModeSurface(); return; } if (state.page) { renderAll(); } else { updateAdminModeSurface(); } } function updateAdminModeSurface() { const isSeo = state.mode === "seo"; const isKnowledge = state.mode === "knowledge"; const hasSite = siteReady(); document.body.dataset.adminMode = state.mode; el.modeNav?.classList.toggle("hidden", !hasSite); if (el.modeContent) el.modeContent.dataset.active = String(!isSeo && !isKnowledge); if (el.modeSeo) el.modeSeo.dataset.active = String(isSeo); if (el.modeKnowledge) el.modeKnowledge.dataset.active = String(isKnowledge); if (el.sidebarTitle) el.sidebarTitle.textContent = isKnowledge ? "База знаний" : isSeo ? "SEO" : "Администрирование"; if (el.workspaceRole) { el.workspaceRole.textContent = isKnowledge ? "Редактор материалов" : isSeo ? "SEO-режим сайта" : projectRole(); } if (el.editorHeading) el.editorHeading.textContent = isKnowledge ? "База знаний" : isSeo ? "SEO сайта" : "Настройки сайта"; if (el.panelKicker) el.panelKicker.textContent = isKnowledge ? "Knowledge" : isSeo ? "SEO" : "Контент"; if (el.panelTitle) el.panelTitle.textContent = isKnowledge ? "Дерево и статья" : isSeo ? "Семантика и сниппеты" : "Редактируемые поля"; if (el.panelNote) { el.panelNote.textContent = isKnowledge ? "Материалы генерируются в статические SEO-страницы раздела /knowledge/." : isSeo ? "Поля готовят страницу и секции к семантической настройке и дальнейшей автоматизации." : "Поля сгруппированы по смысловым подблокам верстки."; } if (el.addBlock) { el.addBlock.title = isKnowledge ? "Добавить раздел в корень базы знаний" : "Добавить блок"; el.addBlock.setAttribute("aria-label", el.addBlock.title); el.addBlock.disabled = !hasSite; } if (el.addProject) { el.addProject.title = "Добавить сайт"; el.addProject.setAttribute("aria-label", "Добавить сайт"); } if (el.save) { el.save.textContent = "Сохранить"; el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json"; el.save.disabled = !hasSite && !isProjectSettingsSelected(); } if (el.render) { el.render.textContent = "Опубликовать"; el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html"; el.render.disabled = !hasSite && !isProjectSettingsSelected(); } if (el.reload) { el.reload.disabled = !hasSite && !isProjectSettingsSelected(); } } function editorJsonPayload(block) { if (state.mode === "seo" && isStaticSelection()) { return state.page?.seo || {}; } return block || {}; } function groupedSeoFields(fields) { const groups = new Map(); for (const seoField of fields) { const groupName = seoField.group || "SEO"; if (!groups.has(groupName)) groups.set(groupName, []); groups.get(groupName).push(seoField); } return groups; } function compactText(value) { if (value == null) return ""; if (Array.isArray(value)) return value.map(compactText).join(" "); if (typeof value === "object") return Object.values(value).map(compactText).join(" "); return String(value) .replace(/<[^>]*>/g, " ") .replace(/https?:\/\/\S+/g, " ") .replace(/\s+/g, " ") .trim(); } function seoLengthStatus(value, min, max) { const length = String(value || "").trim().length; if (!min && !max) return { status: "info", text: length ? `${length} симв.` : "пусто" }; if (!length) return { status: "bad", text: `пусто · цель ${min || 0}-${max || "∞"}` }; if ((min && length < min) || (max && length > max)) { return { status: "warn", text: `${length} симв. · цель ${min || 0}-${max || "∞"}` }; } return { status: "good", text: `${length} симв.` }; } function createSeoMetric(label, value, status = "info") { const item = document.createElement("div"); const caption = document.createElement("span"); const number = document.createElement("strong"); item.className = "seo-metric"; item.dataset.status = status; caption.textContent = label; number.textContent = value; item.append(caption, number); return item; } function blockTextStats(block) { const text = compactText(block?.content || ""); const words = text ? text.split(/\s+/).filter(Boolean).length : 0; return { text, words, chars: text.length }; } function renderSeoDiagnostics({ targetModel, fields, isStatic }) { const diagnostics = document.createElement("section"); const head = document.createElement("div"); const title = document.createElement("h3"); const desc = document.createElement("p"); const metrics = document.createElement("div"); diagnostics.className = "seo-diagnostics"; head.className = "group-head"; title.textContent = isStatic ? "Диагностика страницы" : "Диагностика блока"; desc.className = "group-desc"; desc.textContent = isStatic ? "Базовые поля, которые попадают в head, Open Graph, sitemap и structured data." : "Черновой слой для семантики блока. Позже его сможет заполнять внешний SEO-модуль."; head.append(title, desc); metrics.className = "seo-metric-grid"; if (isStatic) { const titleStatus = seoLengthStatus(getByPath(targetModel, "seo.title"), 35, 70); const descriptionStatus = seoLengthStatus(getByPath(targetModel, "seo.description"), 110, 170); const canonical = String(getByPath(targetModel, "seo.canonicalUrl") || ""); const ogImage = String(getByPath(targetModel, "seo.ogImage") || ""); metrics.append( createSeoMetric("Title", titleStatus.text, titleStatus.status), createSeoMetric("Description", descriptionStatus.text, descriptionStatus.status), createSeoMetric("Canonical", canonical.startsWith("https://") ? "абсолютный" : "проверить", canonical.startsWith("https://") ? "good" : "warn"), createSeoMetric("OG image", ogImage ? "задан" : "пусто", ogImage ? "good" : "bad"), ); } else { const stats = blockTextStats(targetModel); const primaryQuery = String(getByPath(targetModel, "seo.primaryQuery") || "").trim(); const secondary = String(getByPath(targetModel, "seo.secondaryQueries") || "") .split(/\n|,/) .map((item) => item.trim()) .filter(Boolean); const titleStatus = seoLengthStatus(getByPath(targetModel, "seo.title"), 35, 80); metrics.append( createSeoMetric("Текст блока", `${stats.words} слов`, stats.words >= 40 ? "good" : "warn"), createSeoMetric("Основной запрос", primaryQuery ? "задан" : "пусто", primaryQuery ? "good" : "bad"), createSeoMetric("Смежные запросы", `${secondary.length}`, secondary.length ? "good" : "warn"), createSeoMetric("SEO-заголовок", titleStatus.text, titleStatus.status), ); } diagnostics.append(head, metrics); return diagnostics; } function updateSeoFieldMeter(meter, input, config) { if (!meter) return; const lengthStatus = seoLengthStatus(fieldInputValue(input), config.min, config.max); meter.dataset.status = lengthStatus.status; meter.textContent = lengthStatus.text; } function renderSeoInput({ targetModel, seoField, grid }) { const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const path = document.createElement("span"); const isLong = seoField.kind === "textarea"; const input = seoField.kind === "select" ? document.createElement("select") : isLong ? document.createElement("textarea") : document.createElement("input"); const value = getByPath(targetModel, seoField.path) ?? ""; const meter = seoField.min || seoField.max ? document.createElement("span") : null; label.className = `field-control seo-field${isLong ? " wide" : ""}`; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = seoField.label || seoField.path; kind.className = "field-kind"; kind.textContent = seoField.kind || "text"; path.className = "field-path"; path.textContent = seoField.path; labelRow.append(labelText, kind); if (seoField.kind === "select") { const values = new Set([...(seoField.options || []), value].filter(Boolean)); for (const optionValue of values) { const option = document.createElement("option"); option.value = optionValue; option.textContent = optionValue; input.append(option); } } else { if (!isLong) { input.autocomplete = "off"; input.type = seoField.kind === "url" ? "url" : "text"; } } input.dataset.seoPath = seoField.path; input.value = value; input.placeholder = seoField.placeholder || ""; updateSeoFieldMeter(meter, input, seoField); input.addEventListener(seoField.kind === "select" ? "change" : "input", () => { setByPath(targetModel, seoField.path, fieldInputValue(input)); updateSeoFieldMeter(meter, input, seoField); el.json.value = JSON.stringify(editorJsonPayload(activeBlock()), null, 2); markDirty("SEO-поля обновлены локально. Нажмите «Сохранить модель» или «Обновить index.html»."); }); label.append(labelRow, input); if (meter) { meter.className = "seo-field-meter"; label.append(meter); } label.append(path); grid.append(label); } function renderSeoFields(block) { const isStatic = isStaticSelection(); const targetModel = isStatic ? state.page : block; const fields = isStatic ? PAGE_SEO_FIELDS : BLOCK_SEO_FIELDS; if (isStatic) ensurePageSeo(); else ensureBlockSeo(block); el.contentFields.innerHTML = ""; el.contentFields.append(renderSeoDiagnostics({ targetModel, fields, isStatic })); for (const [groupName, groupFields] of groupedSeoFields(fields)) { const group = document.createElement("section"); const head = document.createElement("div"); const titleWrap = document.createElement("div"); const title = document.createElement("h3"); const desc = document.createElement("p"); const grid = document.createElement("div"); group.className = "content-group seo-group"; head.className = "group-head"; title.textContent = groupName; desc.className = "group-desc"; desc.textContent = isStatic ? "Поля уровня страницы и поискового сниппета." : "Поля уровня секции для будущей семантической карты."; titleWrap.append(title, desc); head.append(titleWrap); grid.className = "group-grid"; for (const seoField of groupFields) { renderSeoInput({ targetModel, seoField, grid }); } group.append(head, grid); el.contentFields.append(group); } } const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]); function isWindowTarget(value) { return LINK_WINDOW_TARGETS.has(String(value || "").trim()); } function isExternalHref(value) { return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || "")); } function normalizeLandingTargetCandidate(value) { const raw = String(value || "").trim(); if (!raw || /^javascript:/i.test(raw) || isExternalHref(raw)) return ""; let target = raw; if (target.includes("#")) { target = target.slice(target.indexOf("#") + 1); } target = target .replace(/^\/+/, "") .replace(/^index\.html#?/i, "") .replace(/^#+/, "") .trim(); if (!target || target === "/" || target === "index.html" || target.startsWith("knowledge/") || isWindowTarget(target)) { return ""; } return target; } function landingBlockTargets() { const targets = []; for (const region of state.page?.regions || []) { for (const block of region.blocks || []) { if (block.enabled === false) continue; const target = normalizeLandingTargetCandidate(block.anchor); if (!target) continue; targets.push({ block, id: block.id, label: block.adminLabel || block.id, target, }); } } return targets.sort((left, right) => left.target.localeCompare(right.target, "ru")); } function collectLandingButtonTargets() { return landingBlockTargets().map((item) => item.target); } function findLandingTargetByInput(value) { const input = String(value || "").trim(); const normalized = normalizeLandingTargetCandidate(input); const lowerInput = input.toLowerCase(); const lowerTarget = normalized.toLowerCase(); return ( landingBlockTargets().find((item) => item.target.toLowerCase() === lowerTarget) || landingBlockTargets().find((item) => item.id.toLowerCase() === lowerInput) || landingBlockTargets().find((item) => item.label.toLowerCase() === lowerInput) || null ); } function uniqueLandingTarget(base, exceptBlock = null) { const usedTargets = new Set( landingBlockTargets() .filter((item) => item.block !== exceptBlock) .map((item) => item.target), ); const root = safeBucketSegment(normalizeLandingTargetCandidate(base) || base || "section"); let candidate = root; let index = 2; while (usedTargets.has(candidate)) { candidate = `${root}-${index}`; index += 1; } return candidate; } function landingTargetDuplicates() { const seen = new Map(); const duplicates = []; for (const item of landingBlockTargets()) { if (seen.has(item.target)) { duplicates.push({ target: item.target, first: seen.get(item.target), second: item }); continue; } seen.set(item.target, item); } return duplicates; } function assertUniqueLandingTargets() { const duplicates = landingTargetDuplicates(); if (!duplicates.length) return; const details = duplicates .map(({ target, first, second }) => `${target}: ${first.label} / ${second.label}`) .join("; "); throw new Error(`Target должен быть уникальным для каждого блока. Дубли: ${details}`); } function renderLandingTargetOptions() { if (!el.targetOptions) return; el.targetOptions.innerHTML = ""; for (const { target, label } of landingBlockTargets()) { const option = document.createElement("option"); option.value = target; option.label = label; el.targetOptions.append(option); } } function knowledgeTargetPromptText() { const targets = landingBlockTargets(); const hint = targets.length ? `\n\nДоступные блоки:\n${targets.map((item) => `${item.target} — ${item.label}`).join("\n")}` : ""; return `Target или ID блока лендинга. Если пусто, откроется первый экран.${hint}`; } function normalizeChoiceLink(item, fallback = {}) { if (!item || typeof item !== "object") return; let href = item.href ?? fallback.href ?? ""; let target = item.target ?? fallback.target ?? ""; let linkTarget = item.linkTarget ?? fallback.linkTarget ?? ""; if (isWindowTarget(target)) { linkTarget ||= target; target = ""; } const inferredMode = isExternalHref(href) ? "href" : "target"; const linkMode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : inferredMode; if (linkMode === "target" && !target && href && !isExternalHref(href)) { target = href; href = ""; } item.enabled = item.enabled !== false; item.href = href; item.target = target; item.linkMode = linkMode; item.linkTarget = linkTarget; } function normalizeChoiceLinks(items, fallbacks = []) { if (!Array.isArray(items)) return; items.forEach((item, index) => normalizeChoiceLink(item, fallbacks[index] || fallbacks[0] || {})); } function inferFieldGroup(path) { if (path.startsWith("content.navigation.")) return "Шапка"; if (path.startsWith("content.notifications.")) return "Уведомления"; if (path.startsWith("content.dock.")) return "Dock"; if (path.startsWith("content.assets.")) return "Модель и логотипы"; if (path.startsWith("content.cards.")) return "Тезисы первого экрана"; if (path.startsWith("content.app.") || path.startsWith("content.accessCard.")) return "Карточка доступа"; if (path.startsWith("content.form.")) return "Форма"; if (path === "content.legalHtml" || path.startsWith("content.legal.")) return "Юридический блок"; if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно"; if (path.startsWith("content.features.")) return "Пункты"; if (path.startsWith("content.scroll.")) return "Скролл"; if (path.startsWith("content.sliderItems.")) return "Слайдер"; if (path.startsWith("content.bottomInfo.")) return "Нижний информационный блок"; if (path.startsWith("content.oneClick.")) return "Один клик"; if (path.startsWith("content.mcp.")) return "MCP"; if (path.startsWith("content.faq.")) return "FAQ"; if (path.startsWith("content.cta.")) return "CTA"; if (path.startsWith("content.plans.") || path.startsWith("content.pricing.")) return "Тарифы"; if (path.startsWith("content.pricingCells.")) return "Строки тарифов"; if (path === "content.galleryItems" || path.startsWith("content.galleryItems.")) return "Карточки скриншотов"; if (path === "content.filesEnabled" || path.startsWith("content.files.")) return "TXT-файлы"; if (path === "content.footer.policyEnabled" || path === "content.footer.policyHtml") return "Политики"; if (path.startsWith("content.footer.navLinks.")) return "Навигация футера"; if (path.startsWith("content.footer.contactLinks.")) return "Контактные ссылки"; if (path.startsWith("content.footer.")) return "Футер"; if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок"; return "Контент"; } function createDefaultDockItems(dock = {}, assets = {}) { return [ { enabled: true, title: "NODE.DC", href: "javascript:void(0)", linkTarget: "", targetId: dock.appTarget || "app-data", iconSrc: assets.appIconSrc || STATIC_FALLBACK.content.assets.appIconSrc, iconAlt: assets.appIconAlt || STATIC_FALLBACK.content.assets.appIconAlt, className: "dock-icon nodedc-app", iconClass: "logo", dividerBefore: false, }, { enabled: true, title: "Корзина", href: "javascript:void(0)", linkTarget: "", targetId: "", iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif", iconAlt: dock.trashAlt || "Белая иконка корзины.", className: "dock-icon no-bg", iconClass: "dock-app-img trash", dividerBefore: true, }, ]; } function normalizeDock(dock, assets = {}) { const fallbackItems = createDefaultDockItems(dock, assets); if (!Array.isArray(dock.items)) { dock.items = fallbackItems; } else { dock.items = dock.items.map((item, index) => { const fallback = fallbackItems[index] || fallbackItems[0]; const hasOwn = (key) => Object.prototype.hasOwnProperty.call(item, key); return { enabled: item.enabled !== false, title: item.title || fallback.title || `Элемент ${index + 1}`, href: item.href ?? fallback.href ?? "", linkTarget: item.linkTarget ?? fallback.linkTarget ?? "", targetId: item.targetId ?? fallback.targetId ?? "", iconSrc: hasOwn("iconSrc") ? item.iconSrc : fallback.iconSrc, iconAlt: hasOwn("iconAlt") ? item.iconAlt : item.alt || fallback.iconAlt, className: item.className || fallback.className || "dock-icon nodedc-app", iconClass: hasOwn("iconClass") ? item.iconClass : fallback.iconClass || "logo", dividerBefore: Boolean(item.dividerBefore), }; }); } dock.demoFileLabel ||= STATIC_FALLBACK.content.dock.demoFileLabel; delete dock.appTarget; delete dock.trashAlt; } function ensureStaticElements() { if (!state.page.staticElements) { state.page.staticElements = clone(STATIC_FALLBACK); return; } state.page.staticElements.id ||= STATIC_FALLBACK.id; state.page.staticElements.type ||= STATIC_FALLBACK.type; state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel; state.page.staticElements.enabled = true; state.page.staticElements.content ||= clone(STATIC_FALLBACK.content); state.page.staticElements.content.navigation ||= clone(STATIC_FALLBACK.content.navigation); state.page.staticElements.content.navigation.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc; state.page.staticElements.content.navigation.links ||= clone(STATIC_FALLBACK.content.navigation.links); normalizeChoiceLinks(state.page.staticElements.content.navigation.links, STATIC_FALLBACK.content.navigation.links); state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications); state.page.staticElements.content.notifications.forEach((notification, index) => { notification.enabled = notification.enabled !== false; notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray"; }); state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets); for (const [key, value] of Object.entries(STATIC_FALLBACK.content.assets)) { if (!Object.prototype.hasOwnProperty.call(state.page.staticElements.content.assets, key)) { state.page.staticElements.content.assets[key] = value; } } state.page.staticElements.content.dock ||= clone(STATIC_FALLBACK.content.dock); normalizeDock(state.page.staticElements.content.dock, state.page.staticElements.content.assets); state.page.staticElements.editableFields = clone(STATIC_FALLBACK.editableFields); } function ensureSectionsRegion() { const regions = Array.isArray(state.page.regions) ? state.page.regions : []; const blocks = regions.flatMap((region) => (region.blocks ?? []).map((block) => { block.region ||= region.id; return block; }), ); state.page.regions = [ { id: SECTIONS_REGION_ID, label: SECTIONS_REGION_LABEL, description: "Единый порядок секций главной страницы.", blocks, }, ]; } function sectionsRegionIndex() { return Math.max( 0, state.page.regions.findIndex((region) => region.id === SECTIONS_REGION_ID), ); } function isStaticSelection() { return state.selected?.kind === "static"; } function activeRegion() { if (!state.selected) return null; if (isStaticSelection()) { return { id: "static", label: "Статичные элементы", description: "Shell-слой страницы: шапка, уведомления, dock и базовые ассеты.", }; } return state.page.regions[state.selected.regionIndex]; } function activeBlock() { if (!state.selected) return null; if (isStaticSelection()) { return state.page.staticElements; } const region = activeRegion(); if (!region || state.selected.blockIndex == null) return null; return region.blocks[state.selected.blockIndex]; } function uniqueId(base) { const ids = new Set([ state.page.staticElements?.id, ...state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)), ]); let candidate = `${base}-copy`; let index = 2; while (ids.has(candidate)) { candidate = `${base}-copy-${index}`; index += 1; } return candidate; } function uniqueBlockId(base) { return uniqueId(safeBucketSegment(base || "block").replace(/-copy(?:-\d+)?$/, "")); } function truncateText(value, maxLength = 22) { if (!value) return ""; return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; } function fileNameFromUrl(value) { if (!value) return "Файл не выбран"; try { const clean = value.split("?")[0].split("#")[0]; return decodeURIComponent(clean.slice(clean.lastIndexOf("/") + 1)) || value; } catch { return value; } } function safeBucketSegment(value) { const safe = String(value || "site") .normalize("NFKD") .replace(/[^\w.-]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase(); return safe || "site"; } function adminPreviewUrl(value) { if (!value) return ""; if (/^(blob:|data:|https?:|\/)/i.test(value)) return value; if (value.startsWith("./")) return `.${value}`; return value; } function mediaKindFromValue(value) { if (!value) return null; if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video"; if (/\.(png|jpe?g|gif|svg|ico|avif|webp)(\?.*)?$/i.test(value)) return "image"; if (/\.(glb|gltf)(\?.*)?$/i.test(value)) return "model"; if (/\.(woff2?|ttf|otf)(\?.*)?$/i.test(value)) return "font"; return null; } function formatFileSize(bytes) { const value = Number(bytes); if (!Number.isFinite(value) || value < 0) return ""; if (value < 1024) return `${value} B`; const units = ["KB", "MB", "GB"]; let size = value / 1024; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`; } function formatFileDate(ms) { const value = Number(ms); if (!Number.isFinite(value)) return ""; return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", }).format(new Date(value)); } function mediaUsageStatus(file) { return file?.usage?.status || "unused"; } function mediaUsageLabel(file) { return file?.usage?.label || "Не используется"; } function mediaFileMeta(file) { return [file.kind, file.extension, formatFileSize(file.size)].filter(Boolean).join(" · "); } function selectedMediaFile() { const entry = state.mediaLibrary.selectedEntry; return entry?.type === "file" ? entry : null; } function shouldWarnAboutSolidLogoBackground(editableField) { const path = String(editableField?.path || "").toLowerCase(); const label = String(editableField?.label || "").toLowerCase(); return /logo|icon|wordmark|логотип|икон/.test(`${path} ${label}`); } function uploadBucketForField(block, editableField) { const path = editableField.path.toLowerCase(); const currentValue = String(getByPath(block, editableField.path) ?? "").toLowerCase(); const blockSegment = safeBucketSegment(block?.id || block?.type || "site"); let root = "images"; if (path.includes("webgl") || currentValue.endsWith(".glb") || currentValue.endsWith(".gltf")) root = "models"; if (path.includes("video") || path.includes("media") || path.includes("sliders") || path.includes("slideritems")) root = "media"; if (path.includes("font") || /\.(woff2?|ttf|otf)(\?.*)?$/i.test(currentValue)) root = "fonts"; if (path.includes("favicon")) return "favicon"; return `${root}/${blockSegment}`; } function blockIcon(block) { const id = block.id || ""; const type = block.type || ""; if (id === "static-elements") return "SE"; if (id.includes("gallery") || id.includes("screenshot")) return "SC"; if (id.includes("hero")) return "H"; if (id.includes("feature") || id.includes("video")) return "V"; if (id.includes("component")) return "C"; if (id.includes("modes")) return "AI"; if (id.includes("faq")) return "Q"; if (id.includes("pricing")) return "P"; if (id.includes("waitlist")) return "W"; if (id.includes("footer")) return "F"; if (type.includes("Cta")) return "GO"; return "B"; } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => resolve(String(reader.result))); reader.addEventListener("error", () => reject(reader.error ?? new Error("Не удалось прочитать файл"))); reader.readAsDataURL(file); }); } function labelForRegion(region) { if (region.id === SECTIONS_REGION_ID || region.id === "beforeCViz" || region.id === "cViz") return SECTIONS_REGION_LABEL; return region.label || region.id; } function blockMeta(block) { return block.template || block.type || block.id; } function cleanupDragState() { state.drag = null; state.dragTarget = null; document.querySelectorAll(".dragging, .drag-over, .sorting-source, .sorting-active").forEach((node) => { node.classList.remove("dragging", "drag-over", "sorting-source", "sorting-active"); }); document.querySelectorAll(".sortable-ghost, .sortable-placeholder").forEach((node) => node.remove()); } function createSortableGhost(source, event) { const rect = source.getBoundingClientRect(); const ghost = source.cloneNode(true); ghost.classList.add("sortable-ghost"); ghost.style.width = `${rect.width}px`; ghost.style.height = `${rect.height}px`; ghost.dataset.dragOffsetX = String(event.clientX - rect.left); ghost.dataset.dragOffsetY = String(event.clientY - rect.top); document.body.append(ghost); moveSortableGhost(ghost, event); return ghost; } function moveSortableGhost(ghost, event) { ghost.style.left = `${event.clientX - Number(ghost.dataset.dragOffsetX || 0)}px`; ghost.style.top = `${event.clientY - Number(ghost.dataset.dragOffsetY || 0)}px`; } function createSortablePlaceholder(source) { const rect = source.getBoundingClientRect(); const placeholder = document.createElement("div"); placeholder.className = "sortable-placeholder"; placeholder.style.height = `${rect.height}px`; return placeholder; } function pointInsideRect(event, rect) { return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; } function movePlaceholderByPoint({ event, list, source, placeholder, selector }) { const rect = list.getBoundingClientRect(); if (!pointInsideRect(event, rect)) return; const candidates = [...list.querySelectorAll(selector)].filter((node) => node !== source && node !== placeholder); for (const candidate of candidates) { const candidateRect = candidate.getBoundingClientRect(); if (event.clientY < candidateRect.top + candidateRect.height / 2) { list.insertBefore(placeholder, candidate); return; } } list.append(placeholder); } function sortablePlaceholderIndex({ list, placeholder, source, selector }) { return [...list.querySelectorAll(`${selector}, .sortable-placeholder`)] .filter((node) => node === placeholder || node !== source) .indexOf(placeholder); } function moveBlockWithinRegionToIndex(regionIndex, fromIndex, toIndex) { const region = state.page.regions[regionIndex]; if (!region || fromIndex === toIndex || toIndex < 0) { cleanupDragState(); return; } const [block] = region.blocks.splice(fromIndex, 1); region.blocks.splice(Math.min(toIndex, region.blocks.length), 0, block); state.selected = { kind: "block", regionIndex, blockIndex: Math.min(toIndex, region.blocks.length - 1) }; cleanupDragState(); renderAll(); markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); } function startPointerDrag(event, button, dragContext) { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const list = button.closest(".block-list"); if (!list) return; const placeholder = createSortablePlaceholder(button); const ghost = createSortableGhost(button, event); state.drag = dragContext; state.dragTarget = dragContext; button.classList.add("dragging", "sorting-source"); list.classList.add("sorting-active"); button.after(placeholder); const onMove = (moveEvent) => { moveSortableGhost(ghost, moveEvent); movePlaceholderByPoint({ event: moveEvent, list, source: button, placeholder, selector: ".block-btn.has-drag-handle", }); }; const onFinish = () => { document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onFinish); document.removeEventListener("pointercancel", onCancel); const nextIndex = sortablePlaceholderIndex({ list, placeholder, source: button, selector: ".block-btn.has-drag-handle", }); moveBlockWithinRegionToIndex(dragContext.regionIndex, dragContext.blockIndex, nextIndex); }; const onCancel = () => { document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onFinish); document.removeEventListener("pointercancel", onCancel); cleanupDragState(); }; document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onFinish); document.addEventListener("pointercancel", onCancel); } function reorderBlockWithinRegion(from, to) { if (!from || from.regionIndex !== to.regionIndex || from.blockIndex === to.blockIndex) { cleanupDragState(); return; } const region = state.page.regions[from.regionIndex]; if (!region) { cleanupDragState(); return; } const [block] = region.blocks.splice(from.blockIndex, 1); let insertIndex = to.blockIndex; if (from.blockIndex < to.blockIndex) insertIndex -= 1; region.blocks.splice(insertIndex, 0, block); state.selected = { kind: "block", regionIndex: from.regionIndex, blockIndex: insertIndex }; cleanupDragState(); renderAll(); markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); } function renderBlockButton({ list, block, meta, active, disabled, onClick, dragContext = null }) { const button = document.createElement("div"); const icon = document.createElement("span"); const copy = document.createElement("span"); const name = document.createElement("span"); const description = document.createElement("span"); const canDrag = Boolean(dragContext); button.role = "button"; button.tabIndex = 0; button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}${canDrag ? " has-drag-handle" : ""}`; button.dataset.adminBlockId = block.id || ""; button.dataset.adminBlockType = block.type || ""; button.dataset.adminBlockTemplate = block.template || ""; if (canDrag) { button.dataset.regionIndex = String(dragContext.regionIndex); button.dataset.blockIndex = String(dragContext.blockIndex); } icon.className = "block-icon"; icon.textContent = blockIcon(block); copy.className = "block-copy"; name.className = "block-name"; name.textContent = block.adminLabel || block.id; description.className = "block-meta"; description.textContent = meta; copy.append(name, description); button.append(icon, copy); if (canDrag) { const handle = document.createElement("span"); handle.className = "block-drag-handle"; handle.draggable = false; handle.title = "Перетащить блок"; handle.setAttribute("aria-label", "Перетащить блок"); handle.textContent = "⋮⋮"; handle.addEventListener("click", (event) => event.stopPropagation()); handle.addEventListener("pointerdown", (event) => startPointerDrag(event, button, dragContext)); handle.addEventListener("dragstart", (event) => { state.drag = dragContext; button.classList.add("dragging"); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", `${dragContext.regionIndex}:${dragContext.blockIndex}`); }); handle.addEventListener("dragend", cleanupDragState); handle.addEventListener("keydown", (event) => event.stopPropagation()); button.append(handle); } if (canDrag) { button.addEventListener("dragover", (event) => { if (!state.drag || state.drag.regionIndex !== dragContext.regionIndex) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; button.classList.add("drag-over"); }); button.addEventListener("dragleave", () => button.classList.remove("drag-over")); button.addEventListener("drop", (event) => { event.preventDefault(); button.classList.remove("drag-over"); reorderBlockWithinRegion(state.drag, dragContext); }); } button.addEventListener("click", onClick); button.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onClick(event); }); list.append(button); } function renderRegions() { el.regions.innerHTML = ""; const staticNode = document.createElement("section"); const staticTitle = document.createElement("h3"); const staticList = document.createElement("div"); staticTitle.className = "region-title"; staticTitle.textContent = "Static"; staticList.className = "block-list"; renderBlockButton({ list: staticList, block: state.page.staticElements, meta: "shell · nav · notifications · dock", active: isStaticSelection(), disabled: false, onClick: () => { state.selected = { kind: "static" }; renderAll(); }, }); staticNode.append(staticTitle, staticList); el.regions.append(staticNode); state.page.regions.forEach((region, regionIndex) => { const regionNode = document.createElement("section"); const title = document.createElement("h3"); const list = document.createElement("div"); title.className = "region-title"; title.textContent = labelForRegion(region); list.className = "block-list"; region.blocks.forEach((block, blockIndex) => { const isActive = state.selected?.kind === "block" && state.selected?.regionIndex === regionIndex && state.selected?.blockIndex === blockIndex; renderBlockButton({ list, block, meta: blockMeta(block), active: isActive, disabled: block.enabled === false, dragContext: { regionIndex, blockIndex }, onClick: () => { state.selected = { kind: "block", regionIndex, blockIndex }; renderAll(); }, }); }); regionNode.append(title, list); el.regions.append(regionNode); }); } function blockTemplates() { const regionIndex = sectionsRegionIndex(); return state.templates.map((template) => { return { ...template, regionIndex, regionLabel: SECTIONS_REGION_LABEL, }; }); } function renderTemplateModal() { el.templateList.innerHTML = ""; for (const template of blockTemplates()) { const card = document.createElement("button"); const icon = document.createElement("span"); const copy = document.createElement("span"); const title = document.createElement("span"); const meta = document.createElement("span"); const region = document.createElement("span"); card.type = "button"; card.className = "template-card"; icon.className = "template-card-icon"; icon.textContent = blockIcon(template.block); copy.className = "template-card-copy"; title.className = "template-card-title"; title.textContent = template.block.adminLabel || template.block.id; meta.className = "template-card-meta"; meta.textContent = blockMeta(template.block); region.className = "template-card-region"; region.textContent = `Добавится в: ${template.regionLabel}`; copy.append(title, meta, region); card.append(icon, copy); card.addEventListener("click", () => addBlockFromTemplate(template.key)); el.templateList.append(card); } } function openTemplateModal() { renderTemplateModal(); el.templateModal.classList.remove("hidden"); el.templateList.querySelector("button")?.focus(); } function closeTemplateModal() { el.templateModal.classList.add("hidden"); el.addBlock?.focus(); } function closeDeleteModal(confirmed = false) { const resolve = state.deleteConfirmResolve; state.deleteConfirmResolve = null; el.deleteModal.classList.add("hidden"); if (resolve) resolve(confirmed); } function requestDeleteConfirmation({ title = "Подтвердите действие", body = "Вы действительно подтвердить удаление элемента?", } = {}) { if (state.deleteConfirmResolve) closeDeleteModal(false); return new Promise((resolve) => { state.deleteConfirmResolve = resolve; el.deleteModalTitle.textContent = title; el.deleteModalBody.textContent = body; el.deleteModal.classList.remove("hidden"); el.deleteModalConfirm.focus(); }); } function closeMediaActionModal(value = null) { const resolve = state.mediaActionResolve; state.mediaActionResolve = null; el.mediaActionModal.classList.add("hidden"); if (resolve) resolve(value); } function requestMediaActionInput({ title = "Действие", body = "Введите имя.", label = "Имя", value = "", confirmLabel = "Подтвердить", } = {}) { if (state.mediaActionResolve) closeMediaActionModal(null); return new Promise((resolve) => { state.mediaActionResolve = resolve; el.mediaActionTitle.textContent = title; el.mediaActionBody.textContent = body; el.mediaActionLabel.textContent = label; el.mediaActionConfirm.textContent = confirmLabel; el.mediaActionInput.value = value; el.mediaActionModal.classList.remove("hidden"); el.mediaActionInput.focus(); el.mediaActionInput.select(); }); } function addBlockFromTemplate(templateKey) { const template = blockTemplates().find((candidate) => candidate.key === templateKey); if (!template) return; const region = state.page.regions[template.regionIndex]; const block = clone(template.block); block.id = uniqueBlockId(block.id); block.enabled = true; if (block.anchor) { block.anchor = uniqueLandingTarget(block.anchor || block.adminLabel || block.id, block); } block.scopeIds = true; block.seo = { ...BLOCK_SEO_DEFAULTS, ...(block.seo || {}), }; region.blocks.push(block); state.selected = { kind: "block", regionIndex: template.regionIndex, blockIndex: region.blocks.length - 1 }; closeTemplateModal(); renderAll(); markDirty(`Добавлен блок «${block.adminLabel || block.id}». Нажмите «Сохранить модель» или «Обновить index.html».`); } function setStructuralControlsDisabled(disabled) { for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) { control.disabled = disabled; } } function renderEditorError(error) { console.error(error); el.knowledgeForm.classList.add("hidden"); el.projectSettingsForm.classList.add("hidden"); el.empty.classList.add("hidden"); el.form.classList.remove("hidden"); el.contentFields.innerHTML = ""; const note = document.createElement("div"); note.className = "empty-note editor-error-note"; note.textContent = `Редактор не смог отрисовать поля блока: ${error.message}`; el.contentFields.append(note); setStatus(`Ошибка редактора: ${error.message}`); } function createProjectField({ path, label, type = "text", placeholder = "", options = [], hint = "" }) { const field = document.createElement("label"); const title = document.createElement("span"); const badge = document.createElement("span"); const control = options.length ? document.createElement("select") : document.createElement("input"); const value = projectConfigValue(path); const isSecret = type === "password"; field.className = type === "checkbox" ? "toggle-row" : "field-control"; title.textContent = label; if (type === "checkbox") { control.type = "checkbox"; control.checked = Boolean(value); field.append(control, title); } else { if (options.length) { for (const optionValue of options) { const option = document.createElement("option"); option.value = optionValue; option.textContent = optionValue; control.append(option); } control.value = value || options[0] || ""; } else { control.type = type; control.value = value ?? ""; control.placeholder = placeholder; } badge.className = "field-kind"; badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : isSecret ? "SECRET" : "TEXT"; field.append(title, badge); if (isSecret) { const secretWrap = document.createElement("span"); const reveal = document.createElement("button"); secretWrap.className = "secret-input-wrap"; reveal.className = "secret-toggle-button"; reveal.type = "button"; reveal.innerHTML = ''; reveal.setAttribute("aria-label", "Показать значение"); reveal.title = "Показать значение"; reveal.addEventListener("click", () => { const visible = control.type === "text"; control.type = visible ? "password" : "text"; reveal.setAttribute("aria-label", visible ? "Показать значение" : "Скрыть значение"); reveal.title = visible ? "Показать значение" : "Скрыть значение"; }); secretWrap.append(control, reveal); field.append(secretWrap); } else { field.append(control); } } control.dataset.projectPath = path; control.addEventListener("input", () => { setProjectConfigValue(path, normalizeProjectInputValue(control)); markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки»."); }); control.addEventListener("change", () => { setProjectConfigValue(path, normalizeProjectInputValue(control)); markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки»."); }); if (hint && type !== "checkbox") { const note = document.createElement("small"); note.className = "field-hint"; note.textContent = hint; field.append(note); } return field; } function createProjectSettingsCard({ kicker, title, note = "", fields = [], runtime = false, actions = null }) { const card = document.createElement("section"); const head = document.createElement("div"); const copy = document.createElement("div"); const kickerNode = document.createElement("div"); const titleNode = document.createElement("h3"); const body = document.createElement("div"); card.className = "settings-card"; head.className = "card-head"; copy.append(kickerNode, titleNode); kickerNode.className = "section-kicker"; kickerNode.textContent = kicker; titleNode.textContent = title; head.append(copy); if (note) { const noteNode = document.createElement("p"); noteNode.className = "project-settings-hint"; noteNode.textContent = note; head.append(noteNode); } body.className = runtime ? "project-settings-runtime" : "field-grid"; fields.forEach((field) => body.append(field)); card.append(head, body); if (actions) { const foot = document.createElement("div"); foot.className = "project-settings-actions"; foot.append(...actions); card.append(foot); } return card; } function renderProjectRuntimeLine(label, value) { const row = document.createElement("div"); const code = document.createElement("code"); row.append(`${label}: `, code); code.textContent = value || "не задано"; return row; } function renderEmptyState({ icon = "ND", title, body, stateName = "" }) { el.empty.dataset.state = stateName; el.empty.innerHTML = `
${escapeHtml(icon)}

${escapeHtml(title)}

${escapeHtml(body)}

`; } function renderSiteUnavailable() { updateAdminModeSurface(); applyProjectConfigSurface(); el.regions.innerHTML = ""; el.form.classList.add("hidden"); el.knowledgeForm.classList.add("hidden"); el.projectSettingsForm.classList.add("hidden"); el.empty.classList.remove("hidden"); const missing = siteStatus().missingFiles || []; const missingText = missing.length ? ` Сейчас отсутствуют: ${missing.join(", ")}.` : ""; const root = siteStatus().root || state.projectConfig?.runtime?.siteRoot || ""; const rootText = root ? ` Локальная папка: ${root}.` : ""; renderEmptyState({ icon: "DC", title: "Сайт недоступен", body: `Проверьте настройки подключения к хостингу или загрузите архив сайта в локальный workspace CMS.${missingText}${rootText}`, stateName: "site-unavailable", }); if (el.editorHeading) el.editorHeading.textContent = "DC CMS"; el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`; el.selectedTitle.textContent = "Подключите сайт"; setDirty(false); } function renderProjectSettings() { updateAdminModeSurface(); el.empty.classList.add("hidden"); el.form.classList.add("hidden"); el.knowledgeForm.classList.add("hidden"); el.projectSettingsForm.classList.remove("hidden"); el.projectSettingsForm.innerHTML = ""; if (!state.projectConfig) { el.projectSettingsForm.innerHTML = '
Загружаем настройки проекта...
'; loadProjectConfig().then(renderProjectSettings).catch((error) => setStatus(error.message)); return; } if (el.workspaceRole) el.workspaceRole.textContent = "Настройки подключения"; if (el.editorHeading) el.editorHeading.textContent = "Настройки CMS"; el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`; el.selectedTitle.textContent = "Проект и деплой"; if (el.save) { el.save.textContent = "Сохранить настройки"; el.save.title = "Записать настройки проекта в projects/nodedc.json"; } if (el.render) { el.render.textContent = "Проверить проект"; el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта"; } const projectCard = createProjectSettingsCard({ kicker: "Проект", title: "Подключенный сайт", note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.", fields: [ createProjectField({ path: "name", label: "Название проекта" }), createProjectField({ path: "role", label: "Роль в сайдбаре" }), createProjectField({ path: "siteRoot", label: "Локальный путь сайта", placeholder: "../NODEDC_SITE" }), createProjectField({ path: "previewUrl", label: "Preview URL", placeholder: "http://127.0.0.1:8210/" }), createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }), ], }); const deployCard = createProjectSettingsCard({ kicker: "Хостинг", title: "Публикация на McHost", note: "Для McHost обычно используются FTP host вида aXXXX.ftp.mchost.ru, порт 21 для FTP или 22 для SFTP и папка сайта httpdocs. Пароль не пишем в JSON: passwordEnv хранит имя переменной окружения сервиса.", fields: [ createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }), createProjectField({ path: "deploy.deployOnRender", label: "Деплоить после рендера", type: "checkbox" }), createProjectField({ path: "deploy.method", label: "Метод", options: ["sftp", "ftp"] }), createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }), createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }), createProjectField({ path: "deploy.username", label: "Логин" }), createProjectField({ path: "deploy.passwordEnv", label: "Имя переменной пароля", type: "password", placeholder: "MCHOST_PASSWORD", hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.", }), createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }), ], }); const runtime = state.projectConfig.runtime || {}; const runtimeCard = createProjectSettingsCard({ kicker: "Runtime", title: "Текущая связка", runtime: true, fields: [ renderProjectRuntimeLine("CMS root", runtime.cmsRoot), renderProjectRuntimeLine("Site root", runtime.siteRoot), renderProjectRuntimeLine("Config", runtime.configPath), renderProjectRuntimeLine("Важно", runtime.siteRootChangesRequireRestart ? "изменение siteRoot применится после перезапуска CMS" : ""), ], }); el.projectSettingsForm.append(projectCard, deployCard, runtimeCard); } function renderEditor() { updateAdminModeSurface(); el.knowledgeForm.classList.add("hidden"); el.projectSettingsForm.classList.add("hidden"); const region = activeRegion(); const block = activeBlock(); const isStatic = isStaticSelection(); const isSeo = state.mode === "seo"; if (!block) { el.empty.classList.remove("hidden"); el.form.classList.add("hidden"); renderEmptyState({ icon: "ND", title: "Выберите блок слева", body: "Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.", }); el.selectedRegion.textContent = "NODE.DC / Выберите блок"; el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой"; renderLandingTargetOptions(); return; } el.empty.classList.add("hidden"); el.form.classList.remove("hidden"); el.form.dataset.static = String(isStatic); el.form.dataset.mode = state.mode; el.selectedRegion.textContent = isSeo ? `NODE.DC / SEO / ${isStatic ? "Страница" : region.label || region.id}` : `NODE.DC / ${region.label || region.id}`; el.selectedTitle.textContent = isSeo ? `SEO: ${isStatic ? "страница" : block.adminLabel || block.id}` : block.adminLabel || block.id; el.id.value = block.id || ""; el.label.value = block.adminLabel || ""; el.template.value = block.template || block.type || ""; el.anchor.value = block.anchor || ""; el.anchor.placeholder = "hub-video / engine-video / applied-modules"; el.anchor.title = "Публичный Target конкретного блока: используется как якорь лендинга и как ссылка из Knowledge."; renderLandingTargetOptions(); el.enabled.checked = block.enabled !== false; el.id.readOnly = isStatic; el.anchor.disabled = isStatic; el.enabled.disabled = isStatic; el.enabled.closest(".toggle-row").hidden = isStatic; setStructuralControlsDisabled(isStatic || isSeo); try { if (isSeo) { renderSeoFields(block); } else { renderContentFields(block); } } catch (error) { renderEditorError(error); } el.json.value = JSON.stringify(editorJsonPayload(block), null, 2); } function renderAll() { try { if (!siteReady() && !isProjectSettingsSelected()) { renderSiteUnavailable(); return; } if (isProjectSettingsSelected()) { if (state.mode === "knowledge" && state.knowledge) { ensureKnowledgeSelection(); renderKnowledgeRegions(); } else { renderRegions(); } renderProjectSettings(); return; } if (state.mode === "knowledge") { renderKnowledgeAll(); return; } renderRegions(); renderEditor(); } catch (error) { console.error(error); setStatus(`Ошибка рендера админки: ${error.message}`); } } function syncBlockFromFields() { const block = activeBlock(); if (!block) return; syncContentFields(); block.adminLabel = el.label.value.trim() || block.adminLabel || block.id; if (!isStaticSelection()) { block.id = el.id.value.trim(); block.anchor = normalizeLandingTargetCandidate(el.anchor.value) || null; el.anchor.value = block.anchor || ""; block.enabled = el.enabled.checked; renderLandingTargetOptions(); const duplicate = landingTargetDuplicates().find((item) => item.second.block === block || item.first.block === block); if (duplicate) { setStatus(`Target «${duplicate.target}» уже используется в другом блоке. Переименуйте один из target-ов перед сохранением.`); } } el.json.value = JSON.stringify(block, null, 2); renderRegions(); markDirty(); } function groupEditableFields(block) { const groups = new Map(); for (const editableField of block.editableFields ?? []) { const groupName = editableField.group || inferFieldGroup(editableField.path); if (!groups.has(groupName)) groups.set(groupName, []); groups.get(groupName).push(editableField); } return groups; } function resetMediaPreview(preview, hint) { preview.classList.remove("is-image", "is-video", "is-solid-dark"); preview.innerHTML = ""; preview.title = ""; if (hint) hint.textContent = ""; } function analyzeLogoPreview(image, preview, hint, editableField) { if (!hint || !shouldWarnAboutSolidLogoBackground(editableField)) return; try { const sampleSize = 24; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return; canvas.width = sampleSize; canvas.height = sampleSize; context.drawImage(image, 0, 0, sampleSize, sampleSize); const data = context.getImageData(0, 0, sampleSize, sampleSize).data; let opaque = 0; let edgeOpaque = 0; let edgeDark = 0; for (let y = 0; y < sampleSize; y += 1) { for (let x = 0; x < sampleSize; x += 1) { const offset = (y * sampleSize + x) * 4; const alpha = data[offset + 3]; if (alpha < 240) continue; opaque += 1; const isEdge = x <= 1 || y <= 1 || x >= sampleSize - 2 || y >= sampleSize - 2; const isDark = data[offset] + data[offset + 1] + data[offset + 2] < 66; if (isEdge) { edgeOpaque += 1; if (isDark) edgeDark += 1; } } } const total = sampleSize * sampleSize; const edgeRatio = edgeOpaque ? edgeDark / edgeOpaque : 0; const opaqueRatio = opaque / total; if (opaqueRatio > 0.92 && edgeRatio > 0.82) { preview.classList.add("is-solid-dark"); hint.textContent = "Файл с непрозрачным тёмным фоном. Для логотипа на тёмном интерфейсе лучше PNG/SVG с прозрачностью."; } } catch { // External images can be blocked by canvas CORS. Preview still works, just without diagnostics. } } function renderMediaPreview(preview, value, hint = null, editableField = null) { resetMediaPreview(preview, hint); const src = adminPreviewUrl(value); const kind = mediaKindFromValue(value); if (!src) { preview.textContent = "FILE"; return; } if (kind === "video") { const video = document.createElement("video"); preview.classList.add("is-video"); video.src = src; video.autoplay = true; video.loop = true; video.muted = true; video.playsInline = true; preview.append(video); return; } if (kind === "image") { const image = document.createElement("img"); preview.classList.add("is-image"); image.src = src; image.alt = ""; image.loading = "lazy"; image.addEventListener("load", () => analyzeLogoPreview(image, preview, hint, editableField)); image.addEventListener("error", () => { resetMediaPreview(preview, hint); preview.textContent = "FILE"; if (hint) hint.textContent = "Превью не загрузилось. Проверьте путь или внешний URL."; }); preview.append(image); return; } preview.textContent = "FILE"; } function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, value }) { hiddenInput.value = value; urlInput.value = value; fileName.textContent = truncateText(fileNameFromUrl(value), 28); fileName.title = fileNameFromUrl(value); setByPath(block, editableField.path, value); renderMediaPreview(preview, value, hint, editableField); if (activeBlock() === block) { el.json.value = JSON.stringify(block, null, 2); } markDirty(); } async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, afterUpload, file }) { if (!file) return; state.mediaUploadActive = true; setFilePickerActive(true); recoverEditorSurface(); error.textContent = ""; fileName.textContent = "Сохраняем в assets..."; try { const formData = new FormData(); formData.append("file", file, file.name); formData.append("fileName", file.name); formData.append("mimeType", file.type || "application/octet-stream"); formData.append("bucket", uploadBucketForField(block, editableField)); const response = await fetch("/api/upload/asset", { method: "POST", body: formData, }); const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}`, })); if (!response.ok || !payload.ok) { throw new Error(payload.error || "Файл не удалось сохранить"); } updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, value: payload.url, }); setSource("file"); markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`); if (typeof afterUpload === "function") await afterUpload(payload); return payload; } catch (uploadError) { error.textContent = uploadError.message; fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28); setStatus(`Ошибка загрузки: ${uploadError.message}`); return null; } finally { state.mediaUploadActive = false; mediaFilePicker.value = ""; setFilePickerActive(false); recoverEditorSurface(); } } function openMediaFilePicker(context) { setFilePickerActive(true); recoverEditorSurface(); window.clearTimeout(state.filePickerResetTimer); mediaFilePicker.accept = MEDIA_ACCEPT; mediaFilePicker.value = ""; mediaFilePicker.oncancel = () => { setFilePickerActive(false); recoverEditorSurface(); }; mediaFilePicker.onchange = async () => { const file = mediaFilePicker.files?.[0]; if (!file) { setFilePickerActive(false); recoverEditorSurface(); return; } await uploadMediaFile({ ...context, file }); }; window.addEventListener( "focus", () => { window.setTimeout(() => { if (state.mediaUploadActive || mediaFilePicker.files?.length) return; setFilePickerActive(false); recoverEditorSurface(); }, 180); }, { once: true }, ); try { mediaFilePicker.click(); } catch (error) { setFilePickerActive(false); recoverEditorSurface(); context.error.textContent = error.message; setStatus(`Ошибка открытия выбора файла: ${error.message}`); } } function renderMediaThumb(target, file, { large = false } = {}) { target.innerHTML = ""; target.classList.remove("is-image", "is-video"); if (!file) { target.textContent = "FILE"; return; } if (file.kind === "image") { const image = document.createElement("img"); target.classList.add("is-image"); image.src = adminPreviewUrl(file.url); image.alt = file.name || ""; image.loading = "lazy"; image.decoding = "async"; target.append(image); return; } if (file.kind === "video") { const video = document.createElement("video"); target.classList.add("is-video"); video.src = adminPreviewUrl(file.url); video.muted = true; video.loop = true; video.playsInline = true; video.preload = "metadata"; if (large) video.controls = true; target.append(video); return; } const label = file.kind === "model" ? "3D" : file.kind === "font" ? "FONT" : file.extension?.toUpperCase() || "FILE"; target.textContent = label; } function renderMediaFilterButtons() { el.mediaKindFilters.innerHTML = ""; el.mediaRootFilters.innerHTML = ""; const activeRoot = activeMediaRootFilter(); for (const [kind, label] of MEDIA_KIND_FILTERS) { const button = document.createElement("button"); button.type = "button"; button.className = "media-filter-button"; button.dataset.active = String(state.mediaLibrary.kind === kind); button.disabled = state.mediaLibrary.trashMode; button.textContent = label; button.addEventListener("click", () => { state.mediaLibrary.kind = kind; renderMediaLibrary(); }); el.mediaKindFilters.append(button); } for (const [rootPath, label] of MEDIA_ROOT_FILTERS) { const button = document.createElement("button"); button.type = "button"; button.className = "media-filter-button"; button.dataset.active = String(activeRoot === rootPath); button.disabled = state.mediaLibrary.trashMode; button.textContent = label; button.title = rootPath || "Корень проекта NODEDC_SITE"; button.addEventListener("click", async () => { state.mediaLibrary.root = rootPath; state.mediaLibrary.selectedEntry = null; state.mediaLibrary.selectedUrl = ""; await loadMediaColumns(rootPath).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); }); el.mediaRootFilters.append(button); } } function mediaPathParts(path) { return String(path || "") .split("/") .filter(Boolean); } function isSameOrChildPath(path, rootPath) { if (!rootPath) return !path; return path === rootPath || path.startsWith(`${rootPath}/`); } function activeMediaRootFilter() { const selectedPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""; let activeRoot = ""; for (const [rootPath] of MEDIA_ROOT_FILTERS) { if (rootPath && isSameOrChildPath(selectedPath, rootPath)) { activeRoot = rootPath; } } return activeRoot; } function mediaPathChain(path) { const parts = mediaPathParts(path); const chain = [""]; let current = ""; for (const part of parts) { current = current ? `${current}/${part}` : part; chain.push(current); } return chain; } function parentMediaPath(path) { const parts = mediaPathParts(path); parts.pop(); return parts.join("/"); } function rememberMediaDirectory(path) { const nextPath = String(path || "").replace(/^\/+/, ""); if (nextPath === "__trash__") return; state.mediaLibrary.lastPath = nextPath; updateAdminLayoutState({ lastMediaDirectory: nextPath }); } function pathFromUrl(url) { let value = String(url || "").split("?")[0].split("#")[0]; if (value.startsWith("./")) value = value.slice(2); try { value = decodeURIComponent(value); } catch { // Keep raw value for malformed escape sequences. } return value; } function mediaReplacementVariants(pathOrUrl) { const path = pathFromUrl(pathOrUrl); if (!path) return []; const encodedPath = path .split("/") .map((part) => encodeURIComponent(part)) .join("/"); const variants = [path, `./${path}`, `/${path}`, encodedPath, `./${encodedPath}`, `/${encodedPath}`]; return [...new Set(variants)].sort((left, right) => right.length - left.length); } function replaceMediaReferencesInString(value, replacements) { let nextValue = value; for (const replacement of replacements) { const fromVariants = mediaReplacementVariants(replacement.fromUrl || replacement.from); const toPath = pathFromUrl(replacement.toUrl || replacement.to); if (!fromVariants.length || !toPath) continue; const encodedToPath = toPath .split("/") .map((part) => encodeURIComponent(part)) .join("/"); for (const fromVariant of fromVariants) { const hasDotPrefix = fromVariant.startsWith("./"); const hasRootPrefix = fromVariant.startsWith("/"); const toVariant = `${hasDotPrefix ? "./" : hasRootPrefix ? "/" : ""}${fromVariant.includes("%") ? encodedToPath : toPath}`; nextValue = nextValue.split(fromVariant).join(toVariant); } } return nextValue; } function replaceMediaReferencesInValue(value, replacements) { if (typeof value === "string") { return replaceMediaReferencesInString(value, replacements); } if (Array.isArray(value)) { let changed = false; const nextValue = value.map((item) => { const nextItem = replaceMediaReferencesInValue(item, replacements); changed ||= nextItem !== item; return nextItem; }); return changed ? nextValue : value; } if (value && typeof value === "object") { let changed = false; const nextValue = { ...value }; for (const [key, item] of Object.entries(value)) { const nextItem = replaceMediaReferencesInValue(item, replacements); if (nextItem !== item) { nextValue[key] = nextItem; changed = true; } } return changed ? nextValue : value; } return value; } async function browseMediaPath(path) { const response = await fetch(`/api/media/browse?path=${encodeURIComponent(path || "")}`); const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); if (!response.ok || !payload.ok) { throw new Error(payload.error || "Не удалось открыть папку"); } return payload; } async function refreshMediaSiteSize() { if (!el.mediaSiteSize) return; el.mediaSiteSize.textContent = "Сайт: считаем..."; const response = await fetch("/api/media/site-size"); const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); if (!response.ok || !payload.ok) { el.mediaSiteSize.textContent = "Сайт: --"; throw new Error(payload.error || "Не удалось посчитать размер сайта"); } el.mediaSiteSize.textContent = `Сайт: ${payload.label}`; el.mediaSiteSize.title = `${Number(payload.bytes || 0).toLocaleString("ru-RU")} байт`; } async function postMediaAction(endpoint, payload = {}) { const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const result = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); if (!response.ok || !result.ok) { throw new Error(result.error || "Операция не выполнена"); } return result; } function mediaDragHasEntry(event) { return Array.from(event.dataTransfer?.types || []).includes(MEDIA_DRAG_MIME); } function mediaDragPayload(event) { const rawPayload = event.dataTransfer?.getData(MEDIA_DRAG_MIME); if (!rawPayload) return null; try { return JSON.parse(rawPayload); } catch { return null; } } function syncMediaFieldValueFromServer(context, value) { if (!context || !value) return; const { block, editableField, hiddenInput, urlInput, fileName, preview, hint } = context; hiddenInput.value = value; urlInput.value = value; fileName.textContent = truncateText(fileNameFromUrl(value), 28); fileName.title = fileNameFromUrl(value); setByPath(block, editableField.path, value); renderMediaPreview(preview, value, hint, editableField); if (activeBlock() === block) { el.json.value = JSON.stringify(block, null, 2); } context.setSource("file"); } function applyMediaReferenceReplacements(replacements = []) { const context = state.mediaLibrary.context; if (!Array.isArray(replacements) || !replacements.length) return false; let changed = false; const contextPath = context?.hiddenInput ? pathFromUrl(context.hiddenInput.value || "") : ""; const nextPage = replaceMediaReferencesInValue(state.page, replacements); if (nextPage !== state.page) { state.page = nextPage; changed = true; } document.querySelectorAll("input.service-media-hidden-input").forEach((input) => { const nextValue = replaceMediaReferencesInString(input.value || "", replacements); if (nextValue === input.value) return; input.value = nextValue; const field = input.closest(".service-media-field"); const fileName = field?.querySelector(".service-media-file-name"); const urlInput = field?.querySelector(".service-media-url-input"); if (urlInput) urlInput.value = nextValue; if (fileName) { fileName.textContent = truncateText(fileNameFromUrl(nextValue), 28); fileName.title = fileNameFromUrl(nextValue); } changed = true; }); if (activeBlock()) { el.json.value = JSON.stringify(activeBlock(), null, 2); } if (!context?.hiddenInput) return changed; const match = replacements.find((replacement) => pathFromUrl(replacement.fromUrl || replacement.from || "") === contextPath); if (!match?.toUrl) return changed; syncMediaFieldValueFromServer(context, match.toUrl); return true; } function canDropMediaEntryOnTarget(entry, targetPath) { if (!entry || state.mediaLibrary.trashMode) return false; if (!entry.path) return false; const normalizedTarget = String(targetPath || "").replace(/^\/+/, ""); const sourceParent = parentMediaPath(entry.path); if (entry.type === "directory" && (normalizedTarget === entry.path || normalizedTarget.startsWith(`${entry.path}/`))) { return false; } return normalizedTarget !== sourceParent; } function setMediaDropActive(node, active) { if (!node) return; if (active) { node.dataset.dropActive = "true"; return; } delete node.dataset.dropActive; } function handleMediaDragOver(event, targetPath) { if (!mediaDragHasEntry(event)) return; const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath); event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = canDropMediaEntryOnTarget(entry, targetPath) ? "move" : "none"; setMediaDropActive(event.currentTarget, event.dataTransfer.dropEffect === "move"); } function handleMediaDragLeave(event) { if (event.currentTarget.contains(event.relatedTarget)) return; setMediaDropActive(event.currentTarget, false); } async function moveMediaEntryToPath(entry, targetPath) { if (!canDropMediaEntryOnTarget(entry, targetPath)) return; const payload = await postMediaAction("/api/media/move", { path: entry.path, targetPath }); applyMediaReferenceReplacements(payload.replacements); const nextParentPath = parentMediaPath(payload.path); await loadMediaColumns(nextParentPath, { selectedUrl: payload.url || "" }); const movedEntry = findEntryByPath(payload.path); if (movedEntry) { state.mediaLibrary.selectedPath = nextParentPath; state.mediaLibrary.selectedEntry = movedEntry; state.mediaLibrary.selectedUrl = movedEntry.type === "file" ? movedEntry.url || "" : ""; renderMediaLibrary(); } const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0; setStatus(`Перенесено: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`); } function handleMediaDrop(event, targetPath) { if (!mediaDragHasEntry(event)) return; const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath); event.preventDefault(); event.stopPropagation(); setMediaDropActive(event.currentTarget, false); if (!canDropMediaEntryOnTarget(entry, targetPath)) return; moveMediaEntryToPath(entry, targetPath).catch((error) => setStatus(`Ошибка переноса: ${error.message}`)); } async function browseMediaTrash() { const response = await fetch("/api/media/trash"); const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); if (!response.ok || !payload.ok) { throw new Error(payload.error || "Не удалось открыть удалённые"); } return { ok: true, path: "__trash__", breadcrumbs: [{ name: "Удалённые", path: "__trash__" }], entries: payload.files || [], }; } function entryMatchesMediaFilters(entry) { const query = state.mediaLibrary.query.trim().toLowerCase(); if (query) { const matchesQuery = [entry.name, entry.path, entry.url, mediaUsageLabel(entry)] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query)); if (!matchesQuery) return false; } if (entry.type === "directory") return true; if (state.mediaLibrary.kind === "all") return true; return entry.kind === state.mediaLibrary.kind; } function findEntryByUrl(url) { for (const column of state.mediaLibrary.columns) { const match = column.entries.find((entry) => entry.url === url); if (match) return match; } return null; } function findEntryByPath(path) { if (!path) return null; for (const column of state.mediaLibrary.columns) { const match = column.entries.find((entry) => entry.path === path); if (match) return match; } return null; } function canDeleteMediaEntry(entry) { if (!entry || state.mediaLibrary.trashMode) return false; if (entry.type === "directory") return entry.path?.startsWith("assets/") && !entry.path.split("/").includes(".trash"); const isCurrentFieldValue = state.mediaLibrary.context?.hiddenInput?.value === entry.url; return entry.type === "file" && entry.writable && mediaUsageStatus(entry) === "unused" && !isCurrentFieldValue; } function selectMediaEntry(entry, columnPath = state.mediaLibrary.selectedPath) { if (!entry) return; if (entry.type === "file") { state.mediaLibrary.selectedPath = columnPath; state.mediaLibrary.selectedUrl = entry.url || ""; } else { state.mediaLibrary.selectedUrl = ""; } state.mediaLibrary.selectedEntry = entry; renderMediaLibrary(); } function closeMediaContextMenu() { el.mediaContextMenu.classList.add("hidden"); el.mediaContextMenu.innerHTML = ""; state.mediaLibrary.contextMenu = null; } function appendMediaContextAction(label, action, { disabled = false } = {}) { const button = document.createElement("button"); button.type = "button"; button.setAttribute("role", "menuitem"); button.textContent = label; button.disabled = disabled; button.addEventListener("click", () => { closeMediaContextMenu(); action(); }); el.mediaContextMenu.append(button); } function openMediaContextMenu(event, { entry = null, columnPath = state.mediaLibrary.selectedPath } = {}) { event.preventDefault(); closeMediaContextMenu(); state.mediaLibrary.contextMenu = { entry, columnPath }; if (entry) { selectMediaEntry(entry, columnPath); } if (state.mediaLibrary.trashMode) { if (entry?.type === "file") { appendMediaContextAction("Восстановить", () => restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`))); appendMediaContextAction("Удалить навсегда", () => deleteSelectedTrashFile().catch((error) => setStatus(`Ошибка удаления из корзины: ${error.message}`))); } } else if (entry?.type === "file" || entry?.type === "directory") { appendMediaContextAction("Дублировать", () => duplicateMediaEntry(entry).catch((error) => setStatus(`Ошибка дублирования: ${error.message}`))); appendMediaContextAction("Переименовать", () => renameMediaEntry(entry).catch((error) => setStatus(`Ошибка переименования: ${error.message}`))); appendMediaContextAction("Удалить", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)), { disabled: !canDeleteMediaEntry(entry), }); } else { appendMediaContextAction("Создать папку", () => createMediaFolderAt(columnPath).catch((error) => setStatus(`Ошибка создания папки: ${error.message}`))); } if (!el.mediaContextMenu.children.length) return; el.mediaContextMenu.classList.remove("hidden"); const rect = el.mediaContextMenu.getBoundingClientRect(); const x = Math.min(event.clientX, window.innerWidth - rect.width - 12); const y = Math.min(event.clientY, window.innerHeight - rect.height - 12); el.mediaContextMenu.style.left = `${Math.max(12, x)}px`; el.mediaContextMenu.style.top = `${Math.max(12, y)}px`; } function scrollMediaBrowserToSelection() { window.requestAnimationFrame(() => { const columns = [...el.mediaList.querySelectorAll(".media-column")]; const targetColumn = columns.at(-1); const exactEntry = el.mediaList.querySelector('.media-entry[data-exact-active="true"]'); const activeEntry = exactEntry || el.mediaList.querySelector('.media-entry[data-active="true"]'); if (targetColumn) { const padding = 14; const maxScrollLeft = Math.max(0, el.mediaList.scrollWidth - el.mediaList.clientWidth); const targetRight = targetColumn.offsetLeft + targetColumn.offsetWidth + padding; const targetLeft = targetColumn.offsetLeft - padding; let nextScrollLeft = el.mediaList.scrollLeft; if (targetRight > nextScrollLeft + el.mediaList.clientWidth) { nextScrollLeft = targetRight - el.mediaList.clientWidth; } if (targetLeft < nextScrollLeft) { nextScrollLeft = targetLeft; } el.mediaList.scrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft); } if (activeEntry) { const list = activeEntry.closest(".media-column-list"); if (!list) return; const padding = 8; const nextSlotHeight = activeEntry.offsetHeight + 4; const targetBottom = activeEntry.offsetTop + activeEntry.offsetHeight + nextSlotHeight + padding; const targetTop = activeEntry.offsetTop - padding; const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight); let nextScrollTop = list.scrollTop; if (targetBottom > nextScrollTop + list.clientHeight) { nextScrollTop = targetBottom - list.clientHeight; } if (targetTop < nextScrollTop) { nextScrollTop = targetTop; } list.scrollTop = Math.min(Math.max(nextScrollTop, 0), maxScrollTop); } }); } async function loadMediaColumns(targetPath, { selectedUrl = state.mediaLibrary.selectedUrl } = {}) { const path = String(targetPath || "").replace(/^\/+/, ""); const chain = mediaPathChain(path); const columns = []; state.mediaLibrary.trashMode = false; setStatus("Открываем файловую структуру..."); for (const columnPath of chain) { columns.push(await browseMediaPath(columnPath)); } state.mediaLibrary.columns = columns; state.mediaLibrary.selectedPath = path; state.mediaLibrary.loaded = true; state.mediaLibrary.selectedUrl = selectedUrl || ""; state.mediaLibrary.selectedEntry = selectedUrl ? findEntryByUrl(selectedUrl) : findEntryByPath(path); rememberMediaDirectory(path); renderMediaLibrary(); setStatus(`Файловая структура: ${path || "корень проекта"}.`); } async function loadMediaTrash({ selectedTrashPath = "" } = {}) { setStatus("Открываем удалённые файлы..."); const column = await browseMediaTrash(); state.mediaLibrary.trashMode = true; state.mediaLibrary.columns = [column]; state.mediaLibrary.selectedPath = "__trash__"; state.mediaLibrary.selectedUrl = ""; state.mediaLibrary.loaded = true; state.mediaLibrary.selectedEntry = selectedTrashPath ? findEntryByPath(selectedTrashPath) : column.entries[0] || null; renderMediaLibrary(); setStatus(`Удалённые файлы: ${column.entries.length}.`); } function renderMediaBreadcrumbs() { el.mediaBreadcrumbs.innerHTML = ""; const breadcrumbs = state.mediaLibrary.columns.at(-1)?.breadcrumbs || [{ name: "NODEDC_SITE", path: "" }]; breadcrumbs.forEach((crumb, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "media-breadcrumb-button"; button.textContent = crumb.name; button.dataset.active = String(index === breadcrumbs.length - 1); button.addEventListener("click", () => { state.mediaLibrary.root = crumb.path; state.mediaLibrary.selectedEntry = null; state.mediaLibrary.selectedUrl = ""; loadMediaColumns(crumb.path).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); }); el.mediaBreadcrumbs.append(button); }); } function renderMediaEntry(entry, columnPath) { const button = document.createElement("button"); const icon = document.createElement("span"); const copy = document.createElement("span"); const name = document.createElement("span"); const meta = document.createElement("span"); const statusDot = document.createElement("span"); const arrow = document.createElement("span"); const isExactSelected = state.mediaLibrary.selectedEntry?.path === entry.path; const isSelected = isExactSelected || (entry.type === "directory" && isSameOrChildPath(state.mediaLibrary.selectedPath, entry.path)); button.type = "button"; button.className = "media-entry"; button.dataset.type = entry.type; button.dataset.path = entry.path; button.dataset.active = String(isSelected); button.dataset.exactActive = String(isExactSelected); button.title = entry.url || entry.path; button.draggable = !state.mediaLibrary.trashMode; icon.className = "media-entry-icon"; if (entry.type === "file" && entry.url && entry.kind === "image") { const image = document.createElement("img"); icon.classList.add("is-preview"); image.src = adminPreviewUrl(entry.url); image.alt = ""; image.loading = "lazy"; image.decoding = "async"; icon.append(image); } else if (entry.type === "file" && entry.url && entry.kind === "video") { const video = document.createElement("video"); icon.classList.add("is-preview"); video.src = adminPreviewUrl(entry.url); video.muted = true; video.playsInline = true; video.preload = "metadata"; icon.append(video); } else { icon.textContent = entry.type === "directory" ? "DIR" : entry.kind === "model" ? "3D" : entry.kind === "font" ? "Aa" : entry.extension?.toUpperCase() || "FILE"; } copy.className = "media-entry-copy"; name.className = "media-entry-name"; name.textContent = entry.name; meta.className = "media-entry-meta"; meta.textContent = entry.type === "directory" ? entry.path || "Корень проекта" : [mediaUsageLabel(entry), entry.extension, formatFileSize(entry.size)].filter(Boolean).join(" · "); copy.append(name, meta); statusDot.className = "media-status-dot media-entry-status"; statusDot.dataset.status = mediaUsageStatus(entry); statusDot.title = mediaUsageLabel(entry); arrow.className = "media-entry-arrow"; arrow.textContent = entry.type === "directory" ? "›" : ""; button.append(icon, copy); if (entry.type === "file") button.append(statusDot); button.append(arrow); button.addEventListener("click", async () => { if (entry.type === "directory") { state.mediaLibrary.root = entry.path; state.mediaLibrary.selectedEntry = entry; state.mediaLibrary.selectedUrl = ""; await loadMediaColumns(entry.path); return; } selectMediaEntry(entry, columnPath); }); button.addEventListener("contextmenu", (event) => { event.stopPropagation(); openMediaContextMenu(event, { entry, columnPath }); }); button.addEventListener("dblclick", () => { if (entry.type === "file" && entry.selectable) chooseSelectedMediaFile(); }); button.addEventListener("dragstart", (event) => { if (state.mediaLibrary.trashMode) { event.preventDefault(); return; } const payload = JSON.stringify({ path: entry.path, type: entry.type }); state.mediaLibrary.dragEntryPath = entry.path; button.dataset.dragging = "true"; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData(MEDIA_DRAG_MIME, payload); event.dataTransfer.setData("text/plain", entry.path); }); button.addEventListener("dragend", () => { state.mediaLibrary.dragEntryPath = ""; delete button.dataset.dragging; el.mediaList.querySelectorAll("[data-drop-active]").forEach((node) => setMediaDropActive(node, false)); }); if (entry.type === "directory") { button.addEventListener("dragover", (event) => handleMediaDragOver(event, entry.path)); button.addEventListener("dragleave", handleMediaDragLeave); button.addEventListener("drop", (event) => handleMediaDrop(event, entry.path)); } return button; } function renderMediaColumn(column) { const columnNode = document.createElement("section"); const head = document.createElement("div"); const title = document.createElement("div"); const count = document.createElement("div"); const list = document.createElement("div"); const entries = (column.entries || []).filter(entryMatchesMediaFilters); columnNode.className = "media-column"; head.className = "media-column-head"; title.className = "media-column-title"; title.textContent = column.path ? mediaPathParts(column.path).at(-1) : "NODEDC_SITE"; count.className = "media-column-count"; count.textContent = `${entries.length}`; head.append(title, count); list.className = "media-column-list"; list.dataset.path = column.path || ""; list.addEventListener("dragover", (event) => handleMediaDragOver(event, column.path || "")); list.addEventListener("dragleave", handleMediaDragLeave); list.addEventListener("drop", (event) => handleMediaDrop(event, column.path || "")); list.addEventListener("contextmenu", (event) => { if (event.target.closest(".media-entry")) return; openMediaContextMenu(event, { columnPath: column.path || "" }); }); for (const entry of entries) { list.append(renderMediaEntry(entry, column.path)); } if (!entries.length) { const empty = document.createElement("div"); empty.className = "media-column-empty"; empty.textContent = "Пусто"; list.append(empty); } columnNode.append(head, list); return columnNode; } function mediaColumnByPath(path) { return state.mediaLibrary.columns.find((column) => column.path === path) || null; } function adjacentMediaFileUrl(file) { const selectedPath = state.mediaLibrary.selectedPath || file?.directory || parentMediaPath(pathFromUrl(file?.url || "")); const column = mediaColumnByPath(selectedPath); if (!column) return ""; const visibleFiles = column.entries.filter(entryMatchesMediaFilters).filter((entry) => entry.type === "file"); let index = visibleFiles.findIndex((entry) => entry.path === file.path); let adjacent = index >= 0 ? visibleFiles[index + 1] || visibleFiles[index - 1] : null; if (!adjacent) { const files = column.entries.filter((entry) => entry.type === "file"); index = files.findIndex((entry) => entry.path === file.path); adjacent = index >= 0 ? files[index + 1] || files[index - 1] : null; } return adjacent?.url || ""; } function adjacentMediaEntryPath(entry) { if (!entry?.path) return ""; const column = mediaColumnForEntry(entry) || mediaColumnByPath(parentMediaPath(entry.path)); if (!column) return ""; const entries = visibleMediaEntries(column); let index = entries.findIndex((candidate) => candidate.path === entry.path); let adjacent = index >= 0 ? entries[index + 1] || entries[index - 1] : null; if (!adjacent) { index = column.entries.findIndex((candidate) => candidate.path === entry.path); adjacent = index >= 0 ? column.entries[index + 1] || column.entries[index - 1] : null; } return adjacent?.path || ""; } function visibleMediaEntries(column) { return (column?.entries || []).filter(entryMatchesMediaFilters); } function mediaColumnForEntry(entry) { if (!entry) return null; return state.mediaLibrary.columns.find((column) => column.entries.some((candidate) => candidate.path === entry.path)) || null; } function activeMediaColumn() { return ( mediaColumnForEntry(state.mediaLibrary.selectedEntry) || state.mediaLibrary.columns.find((column) => column.path === state.mediaLibrary.selectedPath) || state.mediaLibrary.columns.at(-1) || null ); } async function moveMediaSelection(delta) { const column = activeMediaColumn(); const entries = visibleMediaEntries(column); if (!column || !entries.length) return; const currentIndex = entries.findIndex((entry) => entry.path === state.mediaLibrary.selectedEntry?.path); const fallbackIndex = delta > 0 ? 0 : entries.length - 1; const nextIndex = currentIndex < 0 ? fallbackIndex : Math.min(Math.max(currentIndex + delta, 0), entries.length - 1); const nextEntry = entries[nextIndex]; if (nextEntry.type === "directory" && !state.mediaLibrary.trashMode) { state.mediaLibrary.root = nextEntry.path; state.mediaLibrary.selectedUrl = ""; await loadMediaColumns(nextEntry.path, { selectedUrl: "" }); const selectedDirectory = findEntryByPath(nextEntry.path); if (selectedDirectory) { state.mediaLibrary.selectedPath = column.path; state.mediaLibrary.selectedEntry = selectedDirectory; state.mediaLibrary.selectedUrl = ""; renderMediaLibrary(); } return; } selectMediaEntry(nextEntry, column.path); } async function openSelectedMediaDirectory() { const entry = state.mediaLibrary.selectedEntry; if (!entry || entry.type !== "directory" || state.mediaLibrary.trashMode) return; state.mediaLibrary.root = entry.path; state.mediaLibrary.selectedEntry = entry; state.mediaLibrary.selectedUrl = ""; await loadMediaColumns(entry.path, { selectedUrl: "" }); const openedColumn = mediaColumnByPath(entry.path); const firstEntry = visibleMediaEntries(openedColumn)[0]; if (firstEntry) { state.mediaLibrary.selectedPath = entry.path; state.mediaLibrary.selectedEntry = firstEntry; state.mediaLibrary.selectedUrl = firstEntry.type === "file" ? firstEntry.url || "" : ""; renderMediaLibrary(); } } async function moveMediaHierarchyBack() { if (state.mediaLibrary.trashMode) return; const path = state.mediaLibrary.selectedPath || state.mediaLibrary.selectedEntry?.path || ""; if (!path) return; const parentPath = parentMediaPath(path); state.mediaLibrary.selectedUrl = ""; await loadMediaColumns(parentPath, { selectedUrl: "" }); const closedDirectory = findEntryByPath(path); if (closedDirectory) { state.mediaLibrary.selectedPath = parentPath; state.mediaLibrary.selectedEntry = closedDirectory; state.mediaLibrary.selectedUrl = ""; renderMediaLibrary(); } } function isTypingTarget(target) { return Boolean(target?.closest?.("input, textarea, select, [contenteditable='true']")); } function renderMediaPreviewPanel() { const file = selectedMediaFile(); const entry = state.mediaLibrary.selectedEntry; const usageText = el.mediaPreviewUsage.lastElementChild; renderMediaThumb(el.mediaPreview, file, { large: true }); el.mediaSelect.textContent = state.mediaLibrary.trashMode ? "Восстановить" : "Выбрать"; el.mediaSelect.disabled = !file; el.mediaDelete.disabled = true; if (!entry) { el.mediaPreviewName.textContent = "Выберите файл или папку"; el.mediaPreviewPath.textContent = state.mediaLibrary.selectedPath || "NODEDC_SITE"; el.mediaPreviewMeta.textContent = "Column view проекта"; el.mediaPreviewUsage.dataset.status = "idle"; if (usageText) usageText.textContent = "Нет выбора"; return; } if (entry.type === "directory") { const canDeleteDirectory = canDeleteMediaEntry(entry); el.mediaPreview.innerHTML = "DIR"; el.mediaPreviewName.textContent = entry.name; el.mediaPreviewPath.textContent = entry.path || "NODEDC_SITE"; el.mediaPreviewPath.title = entry.path || "NODEDC_SITE"; el.mediaPreviewMeta.textContent = "Папка проекта"; el.mediaPreviewUsage.dataset.status = "idle"; if (usageText) usageText.textContent = "Папка"; el.mediaDelete.disabled = !canDeleteDirectory; el.mediaDelete.title = canDeleteDirectory ? "Перенести папку в assets/.trash" : "Удаление доступно только для папок внутри assets"; return; } if (state.mediaLibrary.trashMode) { el.mediaSelect.disabled = !entry.restorable; el.mediaPreviewName.textContent = entry.originalName || entry.name; el.mediaPreviewPath.textContent = entry.originalPath || entry.path; el.mediaPreviewPath.title = entry.originalPath || entry.path; el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · "); el.mediaPreviewUsage.dataset.status = "trash"; if (usageText) usageText.textContent = "Удалён"; el.mediaDelete.disabled = !entry.trashPath; el.mediaDelete.title = "Удалить выбранный файл из корзины навсегда"; return; } const canDelete = canDeleteMediaEntry(entry); el.mediaSelect.disabled = !entry.selectable; el.mediaPreviewName.textContent = entry.name; el.mediaPreviewPath.textContent = entry.url || entry.path; el.mediaPreviewPath.title = entry.url || entry.path; el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · "); el.mediaPreviewUsage.dataset.status = mediaUsageStatus(entry); if (usageText) usageText.textContent = entry.selectable ? mediaUsageLabel(entry) : "Не публичный asset"; el.mediaDelete.disabled = !canDelete; el.mediaDelete.title = canDelete ? "Перенести unused-файл в assets/.trash" : "Удаление доступно только для unused-файлов из assets"; } function renderMediaLibrary() { el.mediaTrashToggle.dataset.active = String(state.mediaLibrary.trashMode); el.mediaUpload.textContent = state.mediaLibrary.trashMode ? "Очистить корзину" : "+ Загрузить"; renderMediaFilterButtons(); renderMediaBreadcrumbs(); el.mediaList.innerHTML = ""; el.mediaList.classList.add("media-columns"); const columns = state.mediaLibrary.columns || []; el.mediaEmpty.classList.toggle("hidden", columns.length > 0); for (const column of columns) { el.mediaList.append(renderMediaColumn(column)); } renderMediaPreviewPanel(); scrollMediaBrowserToSelection(); } async function refreshMediaLibrary() { await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); if (state.mediaLibrary.trashMode) { await loadMediaTrash({ selectedTrashPath: state.mediaLibrary.selectedEntry?.trashPath || "" }); return; } await loadMediaColumns(state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""); } async function toggleMediaTrash() { if (state.mediaLibrary.trashMode) { await loadMediaColumns(state.mediaLibrary.previousPath || state.mediaLibrary.root || ""); return; } state.mediaLibrary.previousPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""; state.mediaLibrary.selectedEntry = null; state.mediaLibrary.selectedUrl = ""; await loadMediaTrash(); } async function openMediaLibrary(context) { const currentValue = context.hiddenInput?.value || ""; const currentValuePath = pathFromUrl(currentValue); const suggestedPath = `assets/uploads/${uploadBucketForField(context.block, context.editableField)}`; const lastPath = state.mediaLibrary.lastPath || readLastMediaDirectory(); const startPath = lastPath || (currentValuePath ? parentMediaPath(currentValuePath) : suggestedPath); state.mediaLibrary.context = context; state.mediaLibrary.trashMode = false; state.mediaLibrary.previousPath = ""; state.mediaLibrary.selectedUrl = currentValue; state.mediaLibrary.selectedEntry = null; state.mediaLibrary.selectedPath = startPath; state.mediaLibrary.query = ""; state.mediaLibrary.root = startPath; state.mediaLibrary.kind = "all"; el.mediaSearch.value = ""; el.mediaModalTitle.textContent = context.editableField.label || "Выбрать файл"; el.mediaModalSubtitle.textContent = `${context.editableField.path} -> ${suggestedPath}`; el.mediaModal.classList.remove("hidden"); el.mediaSearch.focus(); refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); try { await loadMediaColumns(startPath, { selectedUrl: currentValue }); } catch (error) { setStatus(`Ошибка медиатеки: ${error.message}`); renderMediaLibrary(); } } function closeMediaLibrary() { closeMediaContextMenu(); el.mediaModal.classList.add("hidden"); delete el.mediaDropZone.dataset.dragActive; state.mediaLibrary.context = null; state.mediaLibrary.trashMode = false; state.mediaLibrary.selectedUrl = ""; state.mediaLibrary.selectedEntry = null; recoverEditorSurface(); } function chooseSelectedMediaFile() { const file = selectedMediaFile(); const context = state.mediaLibrary.context; if (!file || !context) return; if (state.mediaLibrary.trashMode) { restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`)); return; } if (typeof context.onSelect === "function") { context.onSelect(file.url, file); } else { updateMediaFieldValue({ ...context, value: file.url, }); context.setSource("file"); } rememberMediaDirectory(file.directory || parentMediaPath(pathFromUrl(file.url))); closeMediaLibrary(); markDirty(`Выбран файл: ${file.url}. Нажмите «Сохранить модель», чтобы записать путь.`); } async function restoreSelectedMediaFile() { const file = selectedMediaFile(); if (!file?.trashPath) return; const payload = await postMediaAction("/api/media/restore", { trashPath: file.trashPath }); await loadMediaTrash(); await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); setStatus(`Файл восстановлен: ${payload.path}.`); } async function clearMediaTrash() { const confirmed = await requestDeleteConfirmation({ title: "Очистить корзину?", body: "Все файлы из удалённых будут удалены окончательно.", }); if (!confirmed) return; await postMediaAction("/api/media/trash/clear"); await loadMediaTrash(); await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); setStatus("Корзина очищена."); } async function createMediaFolderAt(path) { if (state.mediaLibrary.trashMode) return; const folderName = await requestMediaActionInput({ title: "Создать папку", body: `Новая папка будет создана в ${path || "корне проекта"}.`, label: "Название папки", value: "new-folder", confirmLabel: "Создать", }); if (!folderName) return; const payload = await postMediaAction("/api/media/folder", { path, name: folderName }); await loadMediaColumns(path); state.mediaLibrary.selectedEntry = findEntryByPath(payload.path); state.mediaLibrary.selectedUrl = ""; renderMediaLibrary(); setStatus(`Папка создана: ${payload.path}.`); } async function renameMediaEntry(entry) { if (!entry || state.mediaLibrary.trashMode) return; const nextName = await requestMediaActionInput({ title: "Переименовать", body: entry.path, label: "Новое имя", value: entry.name, confirmLabel: "Переименовать", }); if (!nextName || nextName === entry.name) return; const payload = await postMediaAction("/api/media/rename", { path: entry.path, name: nextName }); applyMediaReferenceReplacements(payload.replacements); await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" }); const renamedEntry = findEntryByPath(payload.path); if (renamedEntry) { state.mediaLibrary.selectedPath = parentMediaPath(payload.path); state.mediaLibrary.selectedEntry = renamedEntry; state.mediaLibrary.selectedUrl = renamedEntry.type === "file" ? renamedEntry.url || "" : ""; renderMediaLibrary(); } const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0; setStatus(`Переименовано: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`); } async function duplicateMediaEntry(entry) { if (!entry || state.mediaLibrary.trashMode) return; const payload = await postMediaAction("/api/media/duplicate", { path: entry.path }); await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" }); await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); if (!payload.url) { state.mediaLibrary.selectedEntry = findEntryByPath(payload.path); state.mediaLibrary.selectedUrl = ""; renderMediaLibrary(); } setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} продублирован: ${payload.path}.`); } async function uploadIntoMediaLibrary(file) { const context = state.mediaLibrary.context; if (!context || !file) return; await uploadMediaFile({ ...context, file, afterUpload: async (payload) => { state.mediaLibrary.selectedUrl = payload.url; const uploadedPath = pathFromUrl(payload.url); await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url }); await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); }, }); } function openMediaLibraryUploader() { const context = state.mediaLibrary.context; if (!context) return; openMediaFilePicker({ ...context, afterUpload: async (payload) => { state.mediaLibrary.selectedUrl = payload.url; const uploadedPath = pathFromUrl(payload.url); await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url }); await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); }, }); } async function deleteSelectedTrashFile() { const file = selectedMediaFile(); if (!file?.trashPath) return; const nextSelectedPath = adjacentMediaEntryPath(file); const confirmed = await requestDeleteConfirmation({ title: "Удалить из корзины навсегда?", body: "Этот файл будет удалён окончательно. Восстановить его через админку уже не получится.", }); if (!confirmed) return; await postMediaAction("/api/media/trash/delete", { trashPath: file.trashPath }); await loadMediaTrash({ selectedTrashPath: nextSelectedPath }); setStatus(`Файл удалён из корзины навсегда: ${file.originalPath || file.path}.`); } async function deleteSelectedMediaEntry() { const entry = state.mediaLibrary.selectedEntry; if (!entry || el.mediaDelete.disabled) return; if (state.mediaLibrary.trashMode) { await deleteSelectedTrashFile(); return; } const parentPath = entry.type === "file" ? state.mediaLibrary.selectedPath || parentMediaPath(pathFromUrl(entry.url)) : parentMediaPath(entry.path); const nextSelectedPath = adjacentMediaEntryPath(entry); const confirmed = await requestDeleteConfirmation({ title: entry.type === "directory" ? "Удалить папку?" : "Удалить unused-файл?", body: entry.type === "directory" ? "Папка будет перенесена в assets/.trash. Если внутри есть файлы, которые используются в модели или текущей верстке, сервер остановит удаление." : "Файл будет перенесён в assets/.trash. Файлы, которые используются в модели или текущей верстке, сервер не удаляет.", }); if (!confirmed) return; const payload = await postMediaAction("/api/media/delete-entry", { path: entry.path }); await loadMediaColumns(parentPath, { selectedUrl: "" }); await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); const nextEntry = nextSelectedPath ? findEntryByPath(nextSelectedPath) : null; if (nextEntry) { state.mediaLibrary.selectedPath = parentPath; state.mediaLibrary.selectedEntry = nextEntry; state.mediaLibrary.selectedUrl = nextEntry.type === "file" ? nextEntry.url || "" : ""; renderMediaLibrary(); } setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} перенесён в ${payload.trashPath}.`); } function renderMediaField({ block, editableField, grid }) { const value = getByPath(block, editableField.path) ?? ""; const container = document.createElement("div"); const labelRow = document.createElement("div"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const hiddenInput = document.createElement("input"); const control = document.createElement("div"); const fileControl = document.createElement("div"); const fileButton = document.createElement("button"); const fileName = document.createElement("span"); const urlInput = document.createElement("input"); const sourceSwitch = document.createElement("div"); const fileSourceButton = document.createElement("button"); const urlSourceButton = document.createElement("button"); const preview = document.createElement("div"); const path = document.createElement("span"); const hint = document.createElement("span"); const error = document.createElement("span"); let source = /^(https?:)?\/\//i.test(value) ? "url" : "file"; function setSource(nextSource) { source = nextSource; fileControl.hidden = source !== "file"; urlInput.hidden = source !== "url"; fileSourceButton.dataset.active = String(source === "file"); urlSourceButton.dataset.active = String(source === "url"); } container.className = "field-control wide service-media-field"; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = editableField.label || editableField.path; kind.className = "field-kind"; kind.textContent = editableField.kind || "media"; labelRow.append(labelText, kind); hiddenInput.className = "service-media-hidden-input"; hiddenInput.type = "hidden"; hiddenInput.dataset.path = editableField.path; hiddenInput.value = value; control.className = "service-media-control"; fileControl.className = "service-media-file-control"; fileButton.className = "service-media-file-button"; fileButton.type = "button"; fileButton.textContent = "Выберите файл"; fileName.className = "service-media-file-name"; fileName.textContent = truncateText(fileNameFromUrl(value), 28); fileName.title = fileNameFromUrl(value); fileControl.append(fileButton, fileName); urlInput.className = "service-media-url-input"; urlInput.type = "text"; urlInput.placeholder = "https://..."; urlInput.autocomplete = "off"; urlInput.value = value; sourceSwitch.className = "service-media-source-switch"; sourceSwitch.setAttribute("aria-label", `${editableField.label || editableField.path}: источник`); fileSourceButton.className = "service-media-source-button"; fileSourceButton.type = "button"; fileSourceButton.title = "Файл с диска"; fileSourceButton.setAttribute("aria-label", "Файл с диска"); fileSourceButton.textContent = "HD"; urlSourceButton.className = "service-media-source-button"; urlSourceButton.type = "button"; urlSourceButton.title = "Внешняя ссылка"; urlSourceButton.setAttribute("aria-label", "Внешняя ссылка"); urlSourceButton.textContent = "URL"; sourceSwitch.append(fileSourceButton, urlSourceButton); preview.className = "service-media-preview"; renderMediaPreview(preview, value, hint, editableField); path.className = "field-path"; path.textContent = `${editableField.path} -> assets/uploads/${uploadBucketForField(block, editableField)}`; hint.className = "media-upload-hint"; error.className = "media-upload-error"; fileSourceButton.addEventListener("click", () => setSource("file")); urlSourceButton.addEventListener("click", () => { setSource("url"); urlInput.focus(); }); urlInput.addEventListener("input", () => { updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, value: urlInput.value, }); }); fileButton.addEventListener("click", () => { openMediaLibrary({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, }).catch((errorMessage) => setStatus(`Ошибка медиатеки: ${errorMessage.message}`)); }); setSource(source); control.append(fileControl, urlInput, sourceSwitch, preview); container.append(labelRow, hiddenInput, control, path, hint, error); grid.append(container); } function collectionItems(block, editableField) { const value = getByPath(block, editableField.path); if (Array.isArray(value)) return value; const nextValue = []; setByPath(block, editableField.path, nextValue); return nextValue; } function collectionItemTitle(item, index) { return item?.adminLabel || item?.title || item?.name || item?.label || item?.question || item?.windowTitle || `Элемент ${index + 1}`; } function collectionItemTemplate(editableField) { if (editableField.itemTemplate) { const item = clone(editableField.itemTemplate); if (editableField.path === "content.dock.items") { item.iconSrc = ""; item.iconAlt = ""; item.iconClass = ""; } return item; } const item = {}; for (const itemField of editableField.itemFields ?? []) { setByPath(item, itemField.path, itemField.defaultValue ?? ""); } return item; } function uniqueCollectionItemId(items, baseId) { const root = String(baseId || "item").trim() || "item"; const ids = new Set(items.map((item) => item?.id).filter(Boolean)); if (!ids.has(root)) return root; let index = 2; while (ids.has(`${root}-${index}`)) { index += 1; } return `${root}-${index}`; } function prepareCollectionItemForInsert(items, item) { const nextItem = clone(item); if (Object.prototype.hasOwnProperty.call(nextItem, "id")) { nextItem.id = uniqueCollectionItemId(items, nextItem.id); } return nextItem; } function collectionInstanceKey(block, editableField) { return `${block?.id || "static-elements"}::${editableField.path}`; } function stableCollectionPart(value) { return String(value || "") .normalize("NFKD") .replace(/[^\w.-]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase() .slice(0, 80); } function collectionItemKey(item, index) { if (item && typeof item === "object") { if (item.id) return `id:${item.id}`; const stableIdentity = item.targetId || item.href || ""; const stableIdentityPart = stableCollectionPart(stableIdentity); if (stableIdentityPart) return `item:${stableIdentityPart}`; const stableTitle = item.title || item.name || item.label || item.question || item.windowTitle || item.adminLabel || ""; const stableTitlePart = stableCollectionPart(stableTitle); if (stableTitlePart) return `item:${stableTitlePart}`; let runtimeKey = collectionItemRuntimeKeys.get(item); if (!runtimeKey) { collectionItemRuntimeKeyCounter += 1; runtimeKey = `runtime:${collectionItemRuntimeKeyCounter}`; collectionItemRuntimeKeys.set(item, runtimeKey); } return runtimeKey; } return `value:${index}:${String(item)}`; } function persistCollectionOpenState() { const collections = {}; for (const [key, openKeys] of state.collectionOpenState.entries()) { collections[key] = [...openKeys]; } updateAdminLayoutState({ collections }); } function collectionOpenKeys(block, editableField, items) { const key = collectionInstanceKey(block, editableField); let openKeys = state.collectionOpenState.get(key); if (!openKeys) { openKeys = new Set(); if (items.length > 0 && items.length <= 8) { openKeys.add(collectionItemKey(items[0], 0)); } state.collectionOpenState.set(key, openKeys); persistCollectionOpenState(); } return openKeys; } function fieldInputValue(input) { if (input.type === "checkbox") return input.checked; if (input.dataset.valueType === "number" || input.type === "range" || input.type === "number") { const value = Number.parseFloat(input.value); return Number.isFinite(value) ? value : 0; } return input.value; } function syncFieldValue(block, path, input) { setByPath(block, path, fieldInputValue(input)); el.json.value = JSON.stringify(block, null, 2); markDirty(); } function renderBooleanField({ block, editableField, grid }) { const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const path = document.createElement("span"); const input = document.createElement("input"); label.className = "field-control boolean-field"; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = editableField.label || editableField.path; kind.className = "field-kind"; kind.textContent = editableField.kind || "boolean"; path.className = "field-path"; path.textContent = editableField.path; labelRow.append(labelText, kind); input.dataset.path = editableField.path; input.type = "checkbox"; input.checked = Boolean(getByPath(block, editableField.path)); input.addEventListener("change", () => { const active = activeBlock(); if (!active) return; syncFieldValue(active, editableField.path, input); }); label.append(labelRow, input, path); grid.append(label); } function formatRangeValue(value, editableField) { const step = Number.parseFloat(editableField.step ?? 1); const precision = Number.isFinite(step) && step > 0 && step < 1 ? String(step).split(".")[1]?.length || 0 : 0; return `${Number(value).toFixed(precision)}${editableField.suffix || ""}`; } function renderRangeField({ block, editableField, grid }) { const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const path = document.createElement("span"); const control = document.createElement("span"); const input = document.createElement("input"); const output = document.createElement("span"); const min = Number.parseFloat(editableField.min ?? 0); const max = Number.parseFloat(editableField.max ?? 1); const step = Number.parseFloat(editableField.step ?? 0.01); const rawValue = Number.parseFloat(getByPath(block, editableField.path)); const value = Number.isFinite(rawValue) ? rawValue : min; label.className = "field-control range-field wide"; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = editableField.label || editableField.path; kind.className = "field-kind"; kind.textContent = editableField.kind || "range"; path.className = "field-path"; path.textContent = editableField.path; control.className = "range-control"; output.className = "range-value"; labelRow.append(labelText, kind); input.dataset.path = editableField.path; input.dataset.valueType = "number"; input.type = "range"; input.min = Number.isFinite(min) ? String(min) : "0"; input.max = Number.isFinite(max) ? String(max) : "1"; input.step = Number.isFinite(step) ? String(step) : "0.01"; input.value = String(value); output.textContent = formatRangeValue(value, editableField); input.addEventListener("input", () => { const active = activeBlock(); if (!active) return; output.textContent = formatRangeValue(input.value, editableField); syncFieldValue(active, editableField.path, input); }); control.append(input, output); label.append(labelRow, control, path); grid.append(label); } function renderNumberField({ block, editableField, grid }) { const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const path = document.createElement("span"); const control = document.createElement("span"); const input = document.createElement("input"); const suffix = document.createElement("span"); const rawValue = Number.parseFloat(getByPath(block, editableField.path)); const fallback = Number.parseFloat(editableField.defaultValue ?? editableField.min ?? 0); const value = Number.isFinite(rawValue) ? rawValue : fallback; label.className = "field-control number-field"; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = editableField.label || editableField.path; kind.className = "field-kind"; kind.textContent = editableField.kind || "number"; path.className = "field-path"; path.textContent = editableField.path; control.className = "number-control"; suffix.className = "number-suffix"; suffix.textContent = editableField.suffix || ""; labelRow.append(labelText, kind); input.dataset.path = editableField.path; input.dataset.valueType = "number"; input.type = "number"; input.value = Number.isFinite(value) ? String(value) : "0"; input.autocomplete = "off"; if (editableField.min != null) input.min = String(editableField.min); if (editableField.max != null) input.max = String(editableField.max); if (editableField.step != null) input.step = String(editableField.step); input.addEventListener("input", () => { const active = activeBlock(); if (!active) return; syncFieldValue(active, editableField.path, input); }); control.append(input); if (suffix.textContent) control.append(suffix); label.append(labelRow, control, path); grid.append(label); } function siblingFieldPath(path, siblingKey) { const keys = path.split("."); keys[keys.length - 1] = siblingKey; return keys.join("."); } function renderLinkModeField({ block, field, grid }) { const container = document.createElement("div"); const labelRow = document.createElement("div"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const hiddenMode = document.createElement("input"); const rows = document.createElement("div"); const path = document.createElement("span"); const hrefPath = siblingFieldPath(field.path, field.hrefPath || "href"); const targetPath = siblingFieldPath(field.path, field.targetPath || "target"); const currentMode = getByPath(block, field.path) === "href" ? "href" : "target"; function createRow({ mode, title, inputPath, placeholder }) { const row = document.createElement("label"); const check = document.createElement("input"); const caption = document.createElement("span"); const input = document.createElement("input"); row.className = "link-mode-row"; check.type = "checkbox"; check.checked = currentMode === mode; check.setAttribute("aria-label", `${title}: использовать`); caption.textContent = title; input.dataset.path = inputPath; input.type = "text"; input.autocomplete = "off"; input.placeholder = placeholder; input.value = getByPath(block, inputPath) ?? ""; check.addEventListener("change", () => { if (!check.checked) { check.checked = true; return; } const active = activeBlock(); if (!active) return; hiddenMode.value = mode; setByPath(active, field.path, mode); rows.querySelectorAll('input[type="checkbox"]').forEach((candidate) => { candidate.checked = candidate === check; }); el.json.value = JSON.stringify(active, null, 2); markDirty(mode === "href" ? "Активен внешний адрес." : "Активен Target."); }); input.addEventListener("input", () => { const active = activeBlock(); if (!active) return; syncFieldValue(active, inputPath, input); }); row.append(check, caption, input); return row; } container.className = "field-control wide link-mode-field"; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = field.label || "Источник ссылки"; kind.className = "field-kind"; kind.textContent = "link"; labelRow.append(labelText, kind); hiddenMode.type = "hidden"; hiddenMode.dataset.path = field.path; hiddenMode.value = currentMode; rows.className = "link-mode-grid"; rows.append( createRow({ mode: "href", title: "Адрес", inputPath: hrefPath, placeholder: "https://..." }), createRow({ mode: "target", title: "Target", inputPath: targetPath, placeholder: "index.html#section" }), ); path.className = "field-path"; path.textContent = `${field.path} · ${hrefPath} · ${targetPath}`; container.append(labelRow, hiddenMode, rows, path); grid.append(container); } function renderHeaderBooleanToggle({ block, editableField, head }) { const label = document.createElement("label"); const input = document.createElement("input"); const text = document.createElement("span"); label.className = "group-head-toggle"; input.dataset.path = editableField.path; input.type = "checkbox"; input.checked = Boolean(getByPath(block, editableField.path)); text.textContent = "Отображение"; input.addEventListener("change", () => { const active = activeBlock(); if (!active) return; syncFieldValue(active, editableField.path, input); }); label.append(input, text); head.append(label); } function renderCollectionScalarField({ block, field, grid }) { if (field.kind === "link-mode") { renderLinkModeField({ block, field, grid }); return; } if (field.kind === "boolean") { renderBooleanField({ block, editableField: field, grid }); return; } if (field.kind === "range") { renderRangeField({ block, editableField: field, grid }); return; } if (field.kind === "number") { renderNumberField({ block, editableField: field, grid }); return; } const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const path = document.createElement("span"); const value = getByPath(block, field.path) ?? ""; const isLong = field.kind === "html" || field.kind === "textarea"; const input = isLong ? document.createElement("textarea") : document.createElement("input"); label.className = `field-control${isLong ? " wide" : ""}`; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = field.label || field.path; kind.className = "field-kind"; kind.textContent = field.kind || "text"; path.className = "field-path"; path.textContent = field.path; labelRow.append(labelText, kind); input.dataset.path = field.path; input.value = value; if (!isLong) { input.type = "text"; input.autocomplete = "off"; } input.addEventListener("input", () => { const active = activeBlock(); if (!active) return; syncFieldValue(active, field.path, input); }); label.append(labelRow, input, path); grid.append(label); } function startCollectionPointerDrag(event, card, items, index, rerender) { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const list = card.closest(".collection-list"); if (!list) return; const placeholder = createSortablePlaceholder(card); const ghost = createSortableGhost(card, event); card.classList.add("dragging", "sorting-source"); list.classList.add("sorting-active"); card.after(placeholder); const onMove = (moveEvent) => { moveSortableGhost(ghost, moveEvent); movePlaceholderByPoint({ event: moveEvent, list, source: card, placeholder, selector: ".collection-item-card", }); }; const onFinish = () => { document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onFinish); document.removeEventListener("pointercancel", onCancel); const nextIndex = sortablePlaceholderIndex({ list, placeholder, source: card, selector: ".collection-item-card", }); cleanupDragState(); if (nextIndex < 0 || nextIndex === index) return; const [moved] = items.splice(index, 1); items.splice(Math.min(nextIndex, items.length), 0, moved); rerender("Порядок элементов коллекции изменён локально."); }; const onCancel = () => { document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onFinish); document.removeEventListener("pointercancel", onCancel); cleanupDragState(); }; document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onFinish); document.addEventListener("pointercancel", onCancel); } function renderCollectionField({ block, editableField, grid }) { const items = collectionItems(block, editableField); const openKeys = collectionOpenKeys(block, editableField, items); const container = document.createElement("section"); const head = document.createElement("div"); const title = document.createElement("div"); const addButton = document.createElement("button"); const list = document.createElement("div"); function rerender(message = null) { el.json.value = JSON.stringify(block, null, 2); renderContentFields(block); markDirty(message); } container.className = "collection-field"; head.className = "collection-head"; title.className = "collection-title"; title.textContent = editableField.label || editableField.path; addButton.className = "collection-add-btn"; addButton.type = "button"; addButton.title = `Добавить элемент в «${editableField.label || editableField.path}»`; addButton.setAttribute("aria-label", addButton.title); addButton.textContent = "+"; addButton.addEventListener("click", () => { const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)); items.push(nextItem); openKeys.add(collectionItemKey(nextItem, items.length - 1)); persistCollectionOpenState(); rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`); }); head.append(title, addButton); list.className = "collection-list"; items.forEach((item, index) => { const itemKey = collectionItemKey(item, index); const details = document.createElement("details"); const summary = document.createElement("summary"); const summaryTitle = document.createElement("span"); const controls = document.createElement("span"); const dragHandle = document.createElement("button"); const body = document.createElement("div"); const itemGrid = document.createElement("div"); const hasVisibilityToggle = item && typeof item === "object" && (Object.prototype.hasOwnProperty.call(item, "enabled") || Object.prototype.hasOwnProperty.call(editableField.itemTemplate ?? {}, "enabled")); details.className = "collection-item-card"; details.classList.toggle("is-hidden", item?.enabled === false); details.open = openKeys.has(itemKey); details.addEventListener("toggle", () => { if (details.open) openKeys.add(itemKey); else openKeys.delete(itemKey); persistCollectionOpenState(); }); summary.className = "collection-summary"; summaryTitle.className = "collection-summary-title"; summaryTitle.textContent = collectionItemTitle(item, index); controls.className = "collection-item-actions"; dragHandle.className = "collection-drag-handle"; dragHandle.type = "button"; dragHandle.title = "Перетащить элемент"; dragHandle.setAttribute("aria-label", "Перетащить элемент"); dragHandle.innerHTML = ''; dragHandle.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); }); dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender)); if (hasVisibilityToggle) { if (!Object.prototype.hasOwnProperty.call(item, "enabled")) item.enabled = true; const visibilityToggle = document.createElement("span"); const visibilityInput = document.createElement("input"); const visibilityText = document.createElement("span"); visibilityToggle.className = "collection-visibility-toggle"; visibilityToggle.title = "Скрыть/показать"; visibilityToggle.setAttribute("aria-label", "Скрыть/показать"); visibilityInput.type = "checkbox"; visibilityInput.checked = item.enabled !== false; visibilityText.textContent = "Показать"; visibilityToggle.addEventListener("click", (event) => event.stopPropagation()); visibilityInput.addEventListener("click", (event) => event.stopPropagation()); visibilityInput.addEventListener("change", () => { item.enabled = visibilityInput.checked; rerender(visibilityInput.checked ? "Элемент коллекции показан локально." : "Элемент коллекции скрыт локально."); }); visibilityToggle.append(visibilityInput, visibilityText); controls.append(visibilityToggle); } [ ["↑", "Поднять", () => { if (index === 0) return; const [moved] = items.splice(index, 1); items.splice(index - 1, 0, moved); persistCollectionOpenState(); rerender("Порядок элементов коллекции изменён локально."); }], ["↓", "Опустить", () => { if (index >= items.length - 1) return; const [moved] = items.splice(index, 1); items.splice(index + 1, 0, moved); persistCollectionOpenState(); rerender("Порядок элементов коллекции изменён локально."); }], ["⧉", "Дублировать", () => { const nextItem = prepareCollectionItemForInsert(items, item); items.splice(index + 1, 0, nextItem); if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1)); persistCollectionOpenState(); rerender("Элемент коллекции продублирован локально."); }], [FINDER_TRASH_ICON_SVG, "Удалить", async () => { const confirmed = await requestDeleteConfirmation(); if (!confirmed) return; openKeys.delete(itemKey); items.splice(index, 1); persistCollectionOpenState(); rerender("Элемент коллекции удалён локально."); }], ].forEach(([symbol, titleText, handler]) => { const button = document.createElement("button"); button.className = "collection-icon-btn"; button.type = "button"; button.title = titleText; button.setAttribute("aria-label", titleText); if (titleText === "Удалить") { button.innerHTML = symbol; } else { button.textContent = symbol; } button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`)); }); controls.append(button); }); controls.append(dragHandle); summary.append(summaryTitle, controls); body.className = "collection-item-body"; itemGrid.className = "group-grid"; for (const itemField of editableField.itemFields ?? []) { const nestedField = { ...itemField, path: `${editableField.path}.${index}.${itemField.path}`, label: itemField.label || itemField.path, group: editableField.group, }; if (nestedField.kind === "media") { renderMediaField({ block, editableField: nestedField, grid: itemGrid }); } else { renderCollectionScalarField({ block, field: nestedField, grid: itemGrid }); } } body.append(itemGrid); details.append(summary, body); list.append(details); }); container.append(head, list); grid.append(container); } function renderContentFields(block) { el.contentFields.innerHTML = ""; if (!block.editableFields?.length) { const note = document.createElement("div"); note.className = "empty-note"; note.textContent = "Для этого блока typed-поля ещё не выделены. Пока доступно редактирование через JSON."; el.contentFields.append(note); return; } for (const [groupName, fields] of groupEditableFields(block)) { const group = document.createElement("section"); const head = document.createElement("div"); const titleWrap = document.createElement("div"); const title = document.createElement("h3"); const desc = document.createElement("p"); const grid = document.createElement("div"); group.className = "content-group"; head.className = "group-head"; title.textContent = groupName; desc.className = "group-desc"; desc.textContent = GROUP_COPY[groupName] || GROUP_COPY["Контент"]; titleWrap.append(title, desc); head.append(titleWrap); grid.className = "group-grid"; const headerFields = fields.filter((field) => field.kind === "boolean" && field.headerToggle); const bodyFields = fields.filter((field) => !headerFields.includes(field)); headerFields.forEach((editableField) => renderHeaderBooleanToggle({ block, editableField, head })); for (const editableField of bodyFields) { if (editableField.kind === "collection") { renderCollectionField({ block, editableField, grid }); continue; } if (editableField.kind === "media") { renderMediaField({ block, editableField, grid }); continue; } if (editableField.kind === "link-mode") { renderLinkModeField({ block, field: editableField, grid }); continue; } if (editableField.kind === "boolean") { renderBooleanField({ block, editableField, grid }); continue; } if (editableField.kind === "range") { renderRangeField({ block, editableField, grid }); continue; } if (editableField.kind === "number") { renderNumberField({ block, editableField, grid }); continue; } const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kind = document.createElement("span"); const path = document.createElement("span"); const value = getByPath(block, editableField.path) ?? ""; const isLong = editableField.kind === "html" || editableField.kind === "textarea"; const input = isLong ? document.createElement("textarea") : document.createElement("input"); label.className = `field-control${isLong ? " wide" : ""}`; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = editableField.label || editableField.path; kind.className = "field-kind"; kind.textContent = editableField.kind || "text"; path.className = "field-path"; path.textContent = editableField.path; labelRow.append(labelText, kind); input.dataset.path = editableField.path; input.value = value; if (!isLong) { input.type = "text"; input.autocomplete = "off"; } input.addEventListener("input", () => { const active = activeBlock(); if (!active) return; syncFieldValue(active, editableField.path, input); }); label.append(labelRow, input, path); grid.append(label); } group.append(head, grid); el.contentFields.append(group); } } function syncContentFields() { const block = activeBlock(); if (!block) return; for (const input of el.contentFields.querySelectorAll("[data-path]")) { setByPath(block, input.dataset.path, fieldInputValue(input)); } } function syncBlockFromJson() { if (!state.selected) return false; try { const parsed = JSON.parse(el.json.value); if (state.mode === "seo" && isStaticSelection()) { state.page.seo = parsed; } else if (isStaticSelection()) { state.page.staticElements = parsed; } else { const region = activeRegion(); if (!region) return false; region.blocks[state.selected.blockIndex] = parsed; } renderAll(); markDirty("JSON блока применён локально. Нажмите «Сохранить модель», чтобы записать файл."); return true; } catch (error) { setStatus(`Ошибка JSON: ${error.message}`); return false; } } function knowledgeTree() { if (!state.knowledge) return []; if (!Array.isArray(state.knowledge.tree)) state.knowledge.tree = []; return state.knowledge.tree; } function knowledgeWalk(nodes = knowledgeTree(), parent = null, callback = () => {}) { nodes.forEach((node, index) => { callback(node, parent, nodes, index); knowledgeWalk(Array.isArray(node.children) ? node.children : [], node, callback); }); } function allKnowledgeIds() { const ids = new Set(); knowledgeWalk(knowledgeTree(), null, (node) => { if (node.id) ids.add(node.id); }); return ids; } function findKnowledgeNode(id) { let found = null; let foundParent = null; let foundSiblings = null; let foundIndex = -1; knowledgeWalk(knowledgeTree(), null, (node, parent, siblings, index) => { if (found || node.id !== id) return; found = node; foundParent = parent; foundSiblings = siblings; foundIndex = index; }); return found ? { node: found, parent: foundParent, siblings: foundSiblings, index: foundIndex } : null; } function isKnowledgeRootId(id) { return id === KNOWLEDGE_ROOT_ID; } function isKnowledgeRootSelected() { return isKnowledgeRootId(state.knowledgeSelectedId); } function knowledgeRootTitle() { return state.knowledge?.title || state.knowledge?.settings?.navTitle || "Knowledge"; } function selectedKnowledgeNode() { if (isKnowledgeRootSelected()) return state.knowledge; return findKnowledgeNode(state.knowledgeSelectedId)?.node || null; } function knowledgeSlug(value, fallback = "material") { const map = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "c", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya", }; const safe = String(value || "") .toLowerCase() .replace(/[а-яё]/g, (char) => map[char] || "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return safe || fallback; } function uniqueKnowledgeValue(values, base) { const cleanBase = base || "material"; if (!values.has(cleanBase)) return cleanBase; let index = 2; while (values.has(`${cleanBase}-${index}`)) index += 1; return `${cleanBase}-${index}`; } function createKnowledgeNode(title = "Новый материал", siblings = []) { const ids = allKnowledgeIds(); const siblingSlugs = new Set(siblings.map((node) => node.slug).filter(Boolean)); const slug = uniqueKnowledgeValue(siblingSlugs, knowledgeSlug(title)); return { id: uniqueKnowledgeValue(ids, slug), title, slug, enabled: true, useContent: true, seo: { title, description: "", }, bodyHtml: "

Новый материал базы знаний.

", children: [], }; } function ensureKnowledgeSelection() { if (!state.knowledge) return; if (isKnowledgeRootSelected()) return; const existing = state.knowledgeSelectedId ? findKnowledgeNode(state.knowledgeSelectedId) : null; if (existing) return; state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID; } function selectKnowledgeNode(id) { state.knowledgeSelectedId = id; updateAdminLayoutState({ knowledgeSelectedId: id }); renderKnowledgeAll(); } function persistKnowledgeOpenState() { updateAdminLayoutState({ knowledgeOpenIds: [...state.knowledgeOpenIds] }); } function setKnowledgeNodeOpen(id, open) { if (!id) return; if (open) state.knowledgeOpenIds.add(id); else state.knowledgeOpenIds.delete(id); persistKnowledgeOpenState(); } function addKnowledgeNode(parentId = null) { const parent = parentId ? findKnowledgeNode(parentId)?.node : null; const siblings = parent ? (parent.children ||= []) : knowledgeTree(); const node = createKnowledgeNode(parent ? "Новый подраздел" : "Новый раздел", siblings); siblings.push(node); if (parent) setKnowledgeNodeOpen(parent.id, true); state.knowledgeSelectedId = node.id; renderKnowledgeAll(); markDirty("Материал добавлен в дерево базы знаний."); } function moveKnowledgeNode(id, delta) { const match = findKnowledgeNode(id); if (!match) return; const nextIndex = match.index + delta; if (nextIndex < 0 || nextIndex >= match.siblings.length) return; const [node] = match.siblings.splice(match.index, 1); match.siblings.splice(nextIndex, 0, node); renderKnowledgeAll(); markDirty("Порядок дерева базы знаний изменён."); } async function deleteKnowledgeNode(id) { const match = findKnowledgeNode(id); if (!match) return; const confirmed = await requestDeleteConfirmation({ title: "Удалить материал?", body: `«${match.node.title || match.node.id}» и все вложенные элементы будут удалены из дерева.`, }); if (!confirmed) return; match.siblings.splice(match.index, 1); if (state.knowledgeSelectedId === id) { state.knowledgeSelectedId = match.siblings[Math.min(match.index, match.siblings.length - 1)]?.id || match.parent?.id || KNOWLEDGE_ROOT_ID; } renderKnowledgeAll(); markDirty("Материал удалён из дерева базы знаний."); } function renderKnowledgeTreeItem(node, depth = 0) { const item = document.createElement("div"); const row = document.createElement("div"); const select = document.createElement("button"); const icon = document.createElement("span"); const copy = document.createElement("span"); const title = document.createElement("span"); const meta = document.createElement("span"); const actions = document.createElement("span"); const children = Array.isArray(node.children) ? node.children : []; const hasChildren = children.length > 0; const isOpen = state.knowledgeOpenIds.has(node.id) || node.id === state.knowledgeSelectedId || children.some((child) => child.id === state.knowledgeSelectedId); item.className = "knowledge-tree-item"; item.style.setProperty("--depth", String(depth)); row.className = "knowledge-tree-row"; row.dataset.active = String(node.id === state.knowledgeSelectedId); row.dataset.disabled = String(node.enabled === false); select.className = "knowledge-tree-select"; select.type = "button"; icon.className = "knowledge-tree-icon"; icon.textContent = hasChildren ? (isOpen ? "▾" : "›") : node.useContent === false ? "G" : "A"; copy.className = "knowledge-tree-copy"; title.className = "knowledge-tree-title"; title.textContent = node.title || node.id; meta.className = "knowledge-tree-meta"; meta.textContent = node.useContent === false ? "группа" : hasChildren ? "страница + ветка" : "статья"; copy.append(title, meta); select.append(icon, copy); select.addEventListener("click", () => { if (hasChildren) setKnowledgeNodeOpen(node.id, !isOpen); selectKnowledgeNode(node.id); }); actions.className = "knowledge-tree-actions"; [ ["+", "Добавить дочерний материал", () => addKnowledgeNode(node.id)], ["↑", "Поднять", () => moveKnowledgeNode(node.id, -1)], ["↓", "Опустить", () => moveKnowledgeNode(node.id, 1)], [FINDER_TRASH_ICON_SVG, "Удалить", () => deleteKnowledgeNode(node.id)], ].forEach(([symbol, titleText, handler]) => { const button = document.createElement("button"); button.type = "button"; button.className = "knowledge-tree-action"; button.title = titleText; button.setAttribute("aria-label", titleText); if (titleText === "Удалить") button.innerHTML = symbol; else button.textContent = symbol; button.addEventListener("click", (event) => { event.stopPropagation(); Promise.resolve(handler()).catch((error) => setStatus(`Ошибка дерева: ${error.message}`)); }); actions.append(button); }); row.append(select, actions); item.append(row); if (hasChildren && isOpen) { const childList = document.createElement("div"); childList.className = "knowledge-tree-children"; children.forEach((child) => childList.append(renderKnowledgeTreeItem(child, depth + 1))); item.append(childList); } return item; } function renderKnowledgeRootItem() { const item = document.createElement("div"); const row = document.createElement("div"); const select = document.createElement("button"); const icon = document.createElement("span"); const copy = document.createElement("span"); const title = document.createElement("span"); const meta = document.createElement("span"); const actions = document.createElement("span"); const add = document.createElement("button"); item.className = "knowledge-tree-item knowledge-root-item"; item.style.setProperty("--depth", "0"); row.className = "knowledge-tree-row"; row.dataset.active = String(isKnowledgeRootSelected()); row.dataset.disabled = "false"; select.className = "knowledge-tree-select"; select.type = "button"; icon.className = "knowledge-tree-icon"; icon.textContent = "K"; copy.className = "knowledge-tree-copy"; title.className = "knowledge-tree-title"; title.textContent = knowledgeRootTitle(); meta.className = "knowledge-tree-meta"; meta.textContent = "/knowledge/ главная"; copy.append(title, meta); select.append(icon, copy); select.addEventListener("click", () => selectKnowledgeNode(KNOWLEDGE_ROOT_ID)); actions.className = "knowledge-tree-actions"; add.type = "button"; add.className = "knowledge-tree-action"; add.title = "Добавить раздел в корень"; add.setAttribute("aria-label", "Добавить раздел в корень"); add.textContent = "+"; add.addEventListener("click", (event) => { event.stopPropagation(); addKnowledgeNode(null); }); actions.append(add); row.append(select, actions); item.append(row); return item; } function renderKnowledgeRegions() { el.regions.innerHTML = ""; const group = document.createElement("section"); const title = document.createElement("h2"); const root = document.createElement("div"); group.className = "region-group knowledge-region"; title.className = "region-title"; title.textContent = "База знаний"; root.className = "knowledge-tree"; root.append(renderKnowledgeRootItem()); if (!knowledgeTree().length) { const empty = document.createElement("div"); empty.className = "empty-note knowledge-empty-tree"; empty.textContent = "Дерево пустое. Нажмите плюс сверху, чтобы добавить первый раздел."; root.append(empty); } else { knowledgeTree().forEach((node) => root.append(renderKnowledgeTreeItem(node))); } group.append(title, root); el.regions.append(group); } function updateKnowledgeNodeField(node, path, value, message = "Материал базы знаний обновлён.") { setByPath(node, path, value); markDirty(message); } function createKnowledgeInput({ node, path, label, kind = "text", wide = false, placeholder = "" }) { const field = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kindLabel = document.createElement("span"); const input = kind === "textarea" ? document.createElement("textarea") : document.createElement("input"); const pathLabel = document.createElement("span"); field.className = `field-control${wide || kind === "textarea" ? " wide" : ""}`; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = label; kindLabel.className = "field-kind"; kindLabel.textContent = kind; input.value = getByPath(node, path) ?? ""; input.placeholder = placeholder; input.dataset.knowledgePath = path; if (input.tagName === "INPUT") { input.type = kind === "url" ? "url" : "text"; input.autocomplete = "off"; } input.addEventListener("input", () => { updateKnowledgeNodeField(node, path, input.value); if (path === "title" || path === "settings.navTitle") renderKnowledgeRegions(); }); pathLabel.className = "field-path"; pathLabel.textContent = path; labelRow.append(labelText, kindLabel); field.append(labelRow, input, pathLabel); return field; } function createKnowledgeCheckbox({ node, path, label, checkedDefault = true }) { const field = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); const kindLabel = document.createElement("span"); const input = document.createElement("input"); const pathLabel = document.createElement("span"); field.className = "field-control boolean-field"; labelRow.className = "field-label-row"; labelText.className = "field-label-text"; labelText.textContent = label; kindLabel.className = "field-kind"; kindLabel.textContent = "boolean"; input.type = "checkbox"; const value = getByPath(node, path); input.checked = value == null ? checkedDefault : value !== false; input.addEventListener("change", () => { updateKnowledgeNodeField(node, path, input.checked); renderKnowledgeEditor(); renderKnowledgeRegions(); }); pathLabel.className = "field-path"; pathLabel.textContent = path; labelRow.append(labelText, kindLabel); field.append(labelRow, input, pathLabel); return field; } function knowledgeSelectionNodeInsideEditor(editor, node) { if (!editor || !node) return false; const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; return Boolean(element && editor.contains(element)); } function saveKnowledgeEditorSelection() { const selection = window.getSelection(); if (!selection?.rangeCount) return; const editor = el.knowledgeForm.querySelector("[data-knowledge-richtext]"); if (!knowledgeSelectionNodeInsideEditor(editor, selection.anchorNode) || !knowledgeSelectionNodeInsideEditor(editor, selection.focusNode)) return; state.knowledgeSavedSelection = selection.getRangeAt(0).cloneRange(); } function restoreKnowledgeEditorSelection(editor) { editor.focus(); let range = state.knowledgeSavedSelection; if (!range || !knowledgeSelectionNodeInsideEditor(editor, range.commonAncestorContainer)) { range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); state.knowledgeSavedSelection = range.cloneRange(); } const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return range; } function syncKnowledgeRichText(node, editor) { setByPath(node, editor.dataset.knowledgeBodyPath || "bodyHtml", editor.innerHTML.trim()); markDirty("Текст статьи обновлён."); } function execKnowledgeCommand(editor, node, command, value = null) { restoreKnowledgeEditorSelection(editor); document.execCommand(command, false, value); syncKnowledgeRichText(node, editor); } function insertKnowledgeHtml(editor, node, html) { restoreKnowledgeEditorSelection(editor); document.execCommand("insertHTML", false, html); saveKnowledgeEditorSelection(); syncKnowledgeRichText(node, editor); } function escapeKnowledgeShortcodeAttr(value) { return String(value ?? "") .replace(/\s+/g, " ") .trim() .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("[", "(") .replaceAll("]", ")"); } function normalizeKnowledgeLandingTarget(value) { return String(value || "") .trim() .replace(/^#+/, "") .replace(/^index\.html#/i, "") .replace(/^\/?#/, ""); } function normalizeKnowledgeButtonLink(value) { return (String(value ?? "").trim() || "/").replace(/^\/+(https?:\/\/)/i, "$1").replace(/^\/+((?:mailto|tel):)/i, "$1"); } function knowledgeButtonShortcode({ name, link, target, blockId }) { const safeName = escapeKnowledgeShortcodeAttr(name || "Посмотреть"); const safeLink = escapeKnowledgeShortcodeAttr(normalizeKnowledgeButtonLink(link)); const safeTarget = normalizeKnowledgeLandingTarget(target); const safeBlockId = String(blockId || "").trim(); const blockPart = safeBlockId ? ` block="${escapeKnowledgeShortcodeAttr(safeBlockId)}"` : ""; const targetPart = safeTarget ? ` target="${escapeKnowledgeShortcodeAttr(safeTarget)}"` : ""; return `[button name="${safeName}" link="${safeLink}"${blockPart}${targetPart}]`; } function renderKnowledgeButtonTargetOptions() { if (!el.knowledgeButtonTargetOptions) return; el.knowledgeButtonTargetOptions.innerHTML = ""; const added = new Set(); for (const { id, label, target } of landingBlockTargets()) { const targetKey = target.toLowerCase(); if (!added.has(targetKey)) { const option = document.createElement("option"); option.value = target; option.label = `${label} / ${id}`; el.knowledgeButtonTargetOptions.append(option); added.add(targetKey); } const idKey = String(id || "").toLowerCase(); if (id && !added.has(idKey)) { const option = document.createElement("option"); option.value = id; option.label = `${label} / block id`; el.knowledgeButtonTargetOptions.append(option); added.add(idKey); } } } function setKnowledgeButtonError(message = "") { if (el.knowledgeButtonError) el.knowledgeButtonError.textContent = message; } function closeKnowledgeButtonModal(value = null) { const resolve = state.knowledgeButtonResolve; state.knowledgeButtonResolve = null; el.knowledgeButtonModal.classList.add("hidden"); setKnowledgeButtonError(""); if (resolve) resolve(value); } function confirmKnowledgeButtonModal() { const name = el.knowledgeButtonName.value.trim(); if (!name) { setKnowledgeButtonError("Название кнопки обязательно."); el.knowledgeButtonName.focus(); return; } const link = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value); el.knowledgeButtonLink.value = link; closeKnowledgeButtonModal({ name, link, targetInput: el.knowledgeButtonTarget.value.trim(), }); } function requestKnowledgeButtonInput({ name = "Посмотреть демо", link = "/", target = "" } = {}) { if (state.knowledgeButtonResolve) closeKnowledgeButtonModal(null); return new Promise((resolve) => { state.knowledgeButtonResolve = resolve; renderKnowledgeButtonTargetOptions(); setKnowledgeButtonError(""); el.knowledgeButtonName.value = name; el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(link); el.knowledgeButtonTarget.value = normalizeKnowledgeLandingTarget(target); el.knowledgeButtonModal.classList.remove("hidden"); el.knowledgeButtonName.focus(); el.knowledgeButtonName.select(); }); } function openKnowledgeMediaPicker(editor, node) { saveKnowledgeEditorSelection(); const fakeInput = { value: "" }; const fakeText = { textContent: "", title: "" }; const fakePreview = document.createElement("div"); const fakeHint = document.createElement("span"); const fakeError = document.createElement("span"); const fakeUrlInput = { value: "" }; openMediaLibrary({ block: { id: node.id || "knowledge" }, editableField: { path: "content.bodyHtml", label: "Изображение статьи" }, hiddenInput: fakeInput, urlInput: fakeUrlInput, fileName: fakeText, preview: fakePreview, hint: fakeHint, error: fakeError, setSource() {}, onSelect(url) { const safeUrl = String(url || ""); if (!safeUrl) return; insertKnowledgeHtml(editor, node, ``); }, }).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); } function renderKnowledgeToolbar(editor, node) { const toolbar = document.createElement("div"); toolbar.className = "knowledge-rich-toolbar"; const buttons = [ ["P", "Абзац", () => execKnowledgeCommand(editor, node, "formatBlock", "P")], ["H2", "Заголовок H2", () => execKnowledgeCommand(editor, node, "formatBlock", "H2")], ["H3", "Заголовок H3", () => execKnowledgeCommand(editor, node, "formatBlock", "H3")], ["B", "Жирный", () => execKnowledgeCommand(editor, node, "bold")], ["I", "Курсив", () => execKnowledgeCommand(editor, node, "italic")], ["UL", "Список", () => execKnowledgeCommand(editor, node, "insertUnorderedList")], ["OL", "Нумерованный список", () => execKnowledgeCommand(editor, node, "insertOrderedList")], ["Link", "Ссылка", () => { const href = window.prompt("Адрес ссылки", "/knowledge/"); if (!href) return; execKnowledgeCommand(editor, node, "createLink", href); }], ["Button", "Кнопка", async () => { saveKnowledgeEditorSelection(); const values = await requestKnowledgeButtonInput(); if (!values) return; const targetInput = values.targetInput; const landingTarget = findLandingTargetByInput(targetInput); const target = landingTarget ? landingTarget.target : targetInput; const blockId = landingTarget?.id || ""; insertKnowledgeHtml( editor, node, `

${escapeHtml(knowledgeButtonShortcode({ name: values.name, link: values.link, target, blockId }))}

`, ); }], ["Image", "Изображение", () => openKnowledgeMediaPicker(editor, node)], ]; buttons.forEach(([label, title, action]) => { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.title = title; button.addEventListener("mousedown", (event) => event.preventDefault()); button.addEventListener("click", action); toolbar.append(button); }); return toolbar; } function renderKnowledgeEditor() { const isRoot = isKnowledgeRootSelected(); const node = selectedKnowledgeNode(); el.empty.classList.add("hidden"); el.form.classList.add("hidden"); el.projectSettingsForm.classList.add("hidden"); el.knowledgeForm.classList.remove("hidden"); el.knowledgeForm.innerHTML = ""; if (!state.knowledge) { el.knowledgeForm.innerHTML = '
Загружаем базу знаний...
'; return; } if (!node) { const empty = document.createElement("section"); empty.className = "content-panel"; empty.innerHTML = "

Дерево базы знаний пустое

Нажмите плюс в левом верхнем углу, чтобы создать первый раздел.

"; el.knowledgeForm.append(empty); return; } state.knowledge.settings ||= {}; const bodyPath = isRoot ? "introHtml" : "bodyHtml"; const match = isRoot ? null : findKnowledgeNode(node.id); const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний"; el.selectedRegion.textContent = isRoot ? `NODE.DC / ${knowledgeRootTitle()} / /knowledge/` : `NODE.DC / ${state.knowledge.title || "База знаний"} / ${parentTitle}`; el.selectedTitle.textContent = isRoot ? knowledgeRootTitle() : node.title || "Материал"; const metaPanel = document.createElement("section"); const metaHead = document.createElement("div"); const metaTitle = document.createElement("h3"); const metaKicker = document.createElement("div"); const grid = document.createElement("div"); metaPanel.className = "content-panel"; metaHead.className = "panel-head"; metaKicker.className = "section-kicker"; metaKicker.textContent = isRoot ? "Корневая страница" : "Узел дерева"; metaTitle.textContent = isRoot ? "Настройки /knowledge/" : "Параметры материала"; metaHead.append(Object.assign(document.createElement("div"), { innerHTML: `${metaKicker.outerHTML}

${metaTitle.textContent}

` })); grid.className = "group-grid"; if (isRoot) { grid.append( createKnowledgeInput({ node, path: "title", label: "H1 / название в sidebar", wide: false }), createKnowledgeInput({ node, path: "settings.navTitle", label: "Название в шапке", wide: false }), createKnowledgeInput({ node, path: "settings.searchPlaceholder", label: "Placeholder поиска", wide: false }), createKnowledgeInput({ node, path: "settings.homeHref", label: "Ссылка логотипа", kind: "url", wide: false }), createKnowledgeInput({ node, path: "settings.themeDefault", label: "Тема по умолчанию", wide: false }), createKnowledgeCheckbox({ node, path: "settings.downloadEnabled", label: "Показывать Download", checkedDefault: false }), ); } else { grid.append( createKnowledgeInput({ node, path: "title", label: "Название", wide: false }), createKnowledgeInput({ node, path: "slug", label: "Slug", wide: false }), createKnowledgeCheckbox({ node, path: "enabled", label: "Публиковать" }), createKnowledgeCheckbox({ node, path: "useContent", label: "Использовать как информационную страницу" }), ); } metaPanel.append(metaHead, grid); const seoPanel = document.createElement("section"); const seoHead = document.createElement("div"); const seoGrid = document.createElement("div"); seoPanel.className = "content-panel"; seoHead.className = "panel-head"; seoHead.innerHTML = `
SEO

${isRoot ? "Сниппет раздела" : "Сниппет страницы"}

Поля попадут в title, description, Open Graph и sitemap.`; seoGrid.className = "group-grid"; if (isRoot) { seoGrid.append( createKnowledgeInput({ node, path: "settings.description", label: "SEO description / описание", kind: "textarea", wide: true }), ); } else { node.seo ||= {}; seoGrid.append( createKnowledgeInput({ node, path: "seo.title", label: "SEO title", wide: true }), createKnowledgeInput({ node, path: "seo.description", label: "SEO description", kind: "textarea", wide: true }), ); } seoPanel.append(seoHead, seoGrid); const contentPanel = document.createElement("section"); const contentHead = document.createElement("div"); const editor = document.createElement("div"); contentPanel.className = "content-panel knowledge-rich-panel"; contentHead.className = "panel-head"; contentHead.innerHTML = `
Контент

${isRoot ? "Текст главной страницы" : "Текст статьи"}

H2/H3 формируют правый список “На этой странице”.`; editor.className = "knowledge-rich-editor"; editor.contentEditable = isRoot || node.useContent !== false ? "true" : "false"; editor.dataset.knowledgeRichtext = "true"; editor.dataset.knowledgeBodyPath = bodyPath; editor.innerHTML = getByPath(node, bodyPath) || "

"; editor.addEventListener("focusout", () => saveKnowledgeEditorSelection()); editor.addEventListener("keyup", () => saveKnowledgeEditorSelection()); editor.addEventListener("mouseup", () => saveKnowledgeEditorSelection()); editor.addEventListener("input", () => syncKnowledgeRichText(node, editor)); contentPanel.append(contentHead, renderKnowledgeToolbar(editor, node), editor); if (!isRoot && node.useContent === false) { const disabledNote = document.createElement("p"); disabledNote.className = "group-desc"; disabledNote.textContent = "Контент сохранится в модели, но публичная страница для этого узла не будет генерироваться, пока флаг выключен."; contentPanel.append(disabledNote); } const jsonDetails = document.createElement("details"); jsonDetails.className = "json-details knowledge-json"; jsonDetails.innerHTML = `${isRoot ? "JSON всей базы знаний" : "JSON выбранного материала"}`; const jsonField = document.createElement("label"); const json = document.createElement("textarea"); jsonField.className = "json-field"; json.value = JSON.stringify(node, null, 2); json.addEventListener("blur", () => { try { const parsed = JSON.parse(json.value); if (isRoot) { state.knowledge = parsed; state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID; } else { const match = findKnowledgeNode(node.id); if (!match) return; match.siblings[match.index] = parsed; state.knowledgeSelectedId = parsed.id || node.id; } renderKnowledgeAll(); markDirty(isRoot ? "JSON базы знаний применён локально." : "JSON материала применён локально."); } catch (error) { setStatus(`Ошибка JSON ${isRoot ? "базы знаний" : "материала"}: ${error.message}`); } }); jsonField.innerHTML = "Техническое представление"; jsonField.append(json); jsonDetails.append(jsonField); el.knowledgeForm.append(metaPanel, seoPanel, contentPanel, jsonDetails); } function renderKnowledgeAll() { updateAdminModeSurface(); ensureKnowledgeSelection(); renderKnowledgeRegions(); renderKnowledgeEditor(); } async function loadKnowledge() { if (!siteReady()) { renderSiteUnavailable(); return; } const response = await fetch("/api/knowledge"); if (!response.ok) throw new Error(await response.text()); state.knowledge = await response.json(); state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null; ensureKnowledgeSelection(); renderAll(); setDirty(false); setStatus("Модель knowledge.json загружена."); } async function persistKnowledge() { const response = await fetch("/api/knowledge", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(state.knowledge), }); if (!response.ok) throw new Error(await response.text()); } async function saveKnowledge() { await persistKnowledge(); setDirty(false); setStatus("База знаний сохранена в content/knowledge.json."); } async function renderKnowledge() { await persistKnowledge(); const response = await fetch("/api/render/knowledge", { method: "POST" }); const payload = await response.json(); if (!response.ok || !payload.ok) { throw new Error(payload.error || "Render failed"); } setDirty(false); setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана."); } async function loadPage() { const projectConfig = await loadProjectConfig(); state.projectConfig = projectConfig; state.knowledge = null; if (!siteReady()) { state.page = null; state.templates = []; state.selected = null; renderSiteUnavailable(); setStatus("Сайт не загружен в локальный workspace CMS."); return; } const [pageResponse, templatesResponse] = await Promise.all([fetch("/api/page/home"), fetch("/api/block-templates/home")]); if (!pageResponse.ok) throw new Error(await pageResponse.text()); if (!templatesResponse.ok) throw new Error(await templatesResponse.text()); state.page = await pageResponse.json(); state.templates = await templatesResponse.json(); ensureStaticElements(); ensureSectionsRegion(); ensureSeoModel(); state.selected = { kind: "static" }; renderAll(); setDirty(false); setStatus("Модель home.json загружена."); if (state.mode === "knowledge" && !state.knowledge) { await loadKnowledge(); } } async function persistPage() { if (activeBlock()) syncBlockFromFields(); assertUniqueLandingTargets(); const response = await fetch("/api/page/home", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(state.page), }); if (!response.ok) throw new Error(await response.text()); } async function savePage() { await persistPage(); setDirty(false); setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html»."); } async function renderHome() { await persistPage(); const response = await fetch("/api/render/home", { method: "POST" }); const payload = await response.json(); if (!response.ok || !payload.ok) { throw new Error(payload.error || "Render failed"); } setDirty(false); setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран."); } function moveSelected(delta) { if (isStaticSelection()) return; const region = activeRegion(); const index = state.selected?.blockIndex; if (!region || index == null) return; const nextIndex = index + delta; if (nextIndex < 0 || nextIndex >= region.blocks.length) return; const [block] = region.blocks.splice(index, 1); region.blocks.splice(nextIndex, 0, block); state.selected.blockIndex = nextIndex; renderAll(); markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); } function duplicateSelected() { if (isStaticSelection()) return; const region = activeRegion(); const block = activeBlock(); if (!region || !block) return; const duplicate = clone(block); duplicate.id = uniqueId(block.id); duplicate.adminLabel = `${block.adminLabel || block.id} — копия`; if (duplicate.anchor) { duplicate.anchor = uniqueLandingTarget(duplicate.anchor, duplicate); } duplicate.scopeIds = true; region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate); state.selected.blockIndex += 1; renderAll(); markDirty(`Блок «${duplicate.adminLabel || duplicate.id}» продублирован локально.`); } function copySelected() { if (isStaticSelection()) return; const block = activeBlock(); if (!block) return; state.clipboard = clone(block); setStatus(`Скопирован блок: ${block.adminLabel || block.id}`); } function pasteAfterSelected() { if (isStaticSelection()) return; const region = activeRegion(); if (!region || !state.clipboard) return; const pasted = clone(state.clipboard); pasted.id = uniqueId(pasted.id); pasted.adminLabel = `${pasted.adminLabel || pasted.id} — вставка`; if (pasted.anchor) { pasted.anchor = uniqueLandingTarget(pasted.anchor, pasted); } pasted.scopeIds = true; region.blocks.splice(state.selected.blockIndex + 1, 0, pasted); state.selected.blockIndex += 1; renderAll(); markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`); } async function deleteSelected() { if (isStaticSelection()) return; const region = activeRegion(); if (!region || state.selected.blockIndex == null) return; const confirmed = await requestDeleteConfirmation(); if (!confirmed) return; region.blocks.splice(state.selected.blockIndex, 1); state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1); if (state.selected.blockIndex < 0) state.selected = { kind: "static" }; renderAll(); markDirty("Блок удалён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); } function openProjectSettings() { state.selected = { kind: "project-settings" }; renderAll(); } for (const input of [el.id, el.label, el.anchor, el.enabled]) { input.addEventListener("input", syncBlockFromFields); input.addEventListener("change", syncBlockFromFields); } el.modeContent?.addEventListener("click", () => setAdminMode("content")); el.modeSeo?.addEventListener("click", () => setAdminMode("seo")); el.modeKnowledge?.addEventListener("click", () => setAdminMode("knowledge")); el.addProject?.addEventListener("click", openProjectSettings); el.projectSettings?.addEventListener("click", openProjectSettings); el.json.addEventListener("blur", () => { if (state.mode === "knowledge") return; if (state.filePickerActive || state.mediaUploadActive) { recoverEditorSurface(); return; } syncBlockFromJson(); }); el.addBlock?.addEventListener("click", () => { if (state.mode === "knowledge") { addKnowledgeNode(null); return; } openTemplateModal(); }); el.templateModalClose.addEventListener("click", closeTemplateModal); el.templateModalCancel.addEventListener("click", closeTemplateModal); el.templateModal.addEventListener("click", (event) => { if (event.target === el.templateModal) closeTemplateModal(); }); el.deleteModalCancel.addEventListener("click", () => closeDeleteModal(false)); el.deleteModalConfirm.addEventListener("click", () => closeDeleteModal(true)); el.deleteModal.addEventListener("click", (event) => { if (event.target === el.deleteModal) closeDeleteModal(false); }); el.mediaActionCancel.addEventListener("click", () => closeMediaActionModal(null)); el.mediaActionConfirm.addEventListener("click", () => closeMediaActionModal(el.mediaActionInput.value.trim())); el.mediaActionModal.addEventListener("click", (event) => { if (event.target === el.mediaActionModal) closeMediaActionModal(null); }); el.knowledgeButtonClose.addEventListener("click", () => closeKnowledgeButtonModal(null)); el.knowledgeButtonCancel.addEventListener("click", () => closeKnowledgeButtonModal(null)); el.knowledgeButtonConfirm.addEventListener("click", confirmKnowledgeButtonModal); el.knowledgeButtonLink.addEventListener("blur", () => { el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value); }); el.knowledgeButtonModal.addEventListener("click", (event) => { if (event.target === el.knowledgeButtonModal) closeKnowledgeButtonModal(null); }); el.mediaModalClose.addEventListener("click", closeMediaLibrary); el.mediaModalCancel.addEventListener("click", closeMediaLibrary); el.mediaModal.addEventListener("click", (event) => { if (event.target === el.mediaModal) closeMediaLibrary(); }); el.mediaSearch.addEventListener("input", () => { state.mediaLibrary.query = el.mediaSearch.value; renderMediaLibrary(); }); el.mediaRefresh.addEventListener("click", () => refreshMediaLibrary().catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`))); el.mediaUpload.addEventListener("click", () => { if (state.mediaLibrary.trashMode) { clearMediaTrash().catch((error) => setStatus(`Ошибка очистки корзины: ${error.message}`)); return; } openMediaLibraryUploader(); }); el.mediaSelect.addEventListener("click", chooseSelectedMediaFile); el.mediaDelete.addEventListener("click", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`))); el.mediaTrashToggle.addEventListener("click", () => toggleMediaTrash().catch((error) => setStatus(`Ошибка корзины: ${error.message}`))); el.mediaDropZone.addEventListener("dragover", (event) => { if (!state.mediaLibrary.context) return; if (mediaDragHasEntry(event)) return; event.preventDefault(); el.mediaDropZone.dataset.dragActive = "true"; }); el.mediaDropZone.addEventListener("dragleave", (event) => { if (el.mediaDropZone.contains(event.relatedTarget)) return; delete el.mediaDropZone.dataset.dragActive; }); el.mediaDropZone.addEventListener("drop", (event) => { if (!state.mediaLibrary.context) return; if (mediaDragHasEntry(event)) return; event.preventDefault(); delete el.mediaDropZone.dataset.dragActive; const file = event.dataTransfer?.files?.[0]; uploadIntoMediaLibrary(file).catch((error) => setStatus(`Ошибка загрузки: ${error.message}`)); }); document.addEventListener("click", (event) => { if (el.mediaContextMenu.classList.contains("hidden")) return; if (el.mediaContextMenu.contains(event.target)) return; closeMediaContextMenu(); }); document.addEventListener("contextmenu", (event) => { if (el.mediaModal.classList.contains("hidden")) return; if (el.mediaModal.contains(event.target)) return; closeMediaContextMenu(); }); document.addEventListener("selectionchange", () => { if (state.mode !== "knowledge") return; saveKnowledgeEditorSelection(); }); document.addEventListener("keydown", (event) => { if (!el.deleteModal.classList.contains("hidden")) { if (event.key === "Enter") { event.preventDefault(); closeDeleteModal(true); } if (event.key === "Escape") { event.preventDefault(); closeDeleteModal(false); } return; } if (!el.mediaActionModal.classList.contains("hidden")) { if (event.key === "Enter") { event.preventDefault(); closeMediaActionModal(el.mediaActionInput.value.trim()); } if (event.key === "Escape") { event.preventDefault(); closeMediaActionModal(null); } return; } if (!el.knowledgeButtonModal.classList.contains("hidden")) { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); confirmKnowledgeButtonModal(); } if (event.key === "Enter" && event.target !== el.knowledgeButtonTarget) { event.preventDefault(); confirmKnowledgeButtonModal(); } if (event.key === "Escape") { event.preventDefault(); closeKnowledgeButtonModal(null); } return; } if (!el.mediaModal.classList.contains("hidden")) { if (!el.mediaContextMenu.classList.contains("hidden") && event.key === "Escape") { event.preventDefault(); closeMediaContextMenu(); return; } if (isTypingTarget(event.target)) return; if (event.key === "ArrowDown") { event.preventDefault(); moveMediaSelection(1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveMediaSelection(-1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); return; } if (event.key === "ArrowRight") { event.preventDefault(); openSelectedMediaDirectory().catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); return; } if (event.key === "ArrowLeft") { event.preventDefault(); moveMediaHierarchyBack().catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); return; } if (event.key === "Delete" || event.key === "Backspace") { if (state.mediaLibrary.trashMode ? selectedMediaFile()?.trashPath : canDeleteMediaEntry(state.mediaLibrary.selectedEntry)) { event.preventDefault(); deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)); } return; } if (event.key === "Escape") { event.preventDefault(); closeMediaLibrary(); } return; } if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) { closeTemplateModal(); } }); el.reload.addEventListener("click", () => { if (isProjectSettingsSelected()) { loadProjectConfig().then(renderProjectSettings).then(() => setStatus("Настройки проекта перезагружены.")).catch((error) => setStatus(error.message)); return; } const action = state.mode === "knowledge" ? loadKnowledge : loadPage; action().catch((error) => setStatus(error.message)); }); el.save.addEventListener("click", () => { if (isProjectSettingsSelected()) { saveProjectSettings().catch((error) => setStatus(error.message)); return; } const action = state.mode === "knowledge" ? saveKnowledge : savePage; action().catch((error) => setStatus(error.message)); }); el.render.addEventListener("click", () => { if (isProjectSettingsSelected()) { checkProjectConnection().catch((error) => setStatus(error.message)); return; } const action = state.mode === "knowledge" ? renderKnowledge : renderHome; action().catch((error) => setStatus(error.message)); }); el.moveUp.addEventListener("click", () => moveSelected(-1)); el.moveDown.addEventListener("click", () => moveSelected(1)); el.duplicate.addEventListener("click", duplicateSelected); el.copy.addEventListener("click", copySelected); el.pasteAfter.addEventListener("click", pasteAfterSelected); el.deleteBlock.addEventListener("click", () => deleteSelected().catch((error) => setStatus(`Ошибка удаления: ${error.message}`))); window.addEventListener("error", (event) => { recoverEditorSurface(); setStatus(`Ошибка JS: ${event.message}`); }); window.addEventListener("unhandledrejection", (event) => { recoverEditorSurface(); const reason = event.reason instanceof Error ? event.reason.message : String(event.reason); setStatus(`Ошибка JS: ${reason}`); }); loadPage().catch((error) => setStatus(error.message));