const state = { page: null, templates: [], selected: null, clipboard: null, drag: null, dragTarget: null, dirty: false, filePickerActive: false, mediaUploadActive: false, filePickerResetTimer: null, }; const el = { regions: document.querySelector("#regions"), 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"), 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"), 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"), }; const STATIC_FALLBACK = { id: "static-elements", type: "staticElements", adminLabel: "Статичные элементы", enabled: true, content: { navigation: { logoSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg", logoAlt: "главная", brandLabel: "NODE.DC", links: [ { label: "О продукте", href: "index.html#features", target: "" }, { label: "Лист ожидания", href: "index.html#hero", target: "" }, { label: "Документация", href: "https://docs.ssscript.app/", target: "_blank" }, { label: "Тарифы", href: "index.html#pricing-wrap", target: "" }, ], }, notifications: [ { eyebrowHtml: 'ДАЛЕЕ — ИЮЛЬ 2026', eyebrowClass: "red-txt gray", title: "Открытая бета", body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", }, { eyebrowHtml: 'ДАЛЕЕ ИЮЛЬ 2026', eyebrowClass: "red-txt gray", title: "Альфа для Founder-доступа", body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.", }, { eyebrowHtml: "СЕЙЧАС", eyebrowClass: "red-txt", title: "Инвайты в закрытую бету уже отправлены.", body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ", }, ], dock: { items: [ { enabled: true, title: "NODE.DC", href: "#hero", linkTarget: "", targetId: "app-data", iconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg", iconAlt: "Иконка NODE.DC", className: "dock-icon ssscript", iconClass: "logo", dividerBefore: false, }, { enabled: true, title: "Корзина", href: "", linkTarget: "", targetId: "", iconSrc: "./assets/webflow/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/custom/logo.png", logoPngAlt: "Логотип NODE.DC", appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg", appIconAlt: "Иконка NODE.DC", appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc", appWordmarkAlt: "Логотип NODE.DC", }, }, editableFields: [ field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"), field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"), field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"), collection( "content.navigation.links", "Кнопки шапки", "Шапка: ссылки", { label: "Новая ссылка", href: "index.html#", target: "" }, [ field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"), field("href", "Адрес", "url", "attr", "Шапка: ссылки"), field("target", "Target", "text", "attr", "Шапка: ссылки"), ], ), collection( "content.notifications", "Уведомления", "Уведомления", { 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: "#hero", linkTarget: "", targetId: "", iconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg", iconAlt: "Иконка приложения", className: "dock-icon ssscript", iconClass: "logo", 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", "Модель и логотипы"), ], }; const GROUP_COPY = { "Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.", "Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.", Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.", Dock: "Нижняя системная панель: приложения, корзина, ссылки и подписи файлов.", "Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.", Заголовок: "Главный заголовок и вводный текст выбранной секции.", "Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.", "Карточка доступа": "Логотипы, тексты и видимость центрального окна приложения.", Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.", "Юридический блок": "Политики, права и нижний текст формы.", "Видео-окно": "Окно демо-видео и подключенный медиа-файл.", Пункты: "Текстовые пункты внутри секции.", Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.", "Один клик": "Подблок про быстрое добавление и настройку компонентов.", MCP: "Подблок про MCP-сервер и контекст Webflow.", FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.", CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.", Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.", "Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.", "Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.", "TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.", Футер: "Нижняя навигация, контактные ссылки, политики и брендовый блок.", Контент: "Остальные typed-поля выбранного блока.", }; const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2"; const mediaFilePicker = createMediaFilePicker(); 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.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 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 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") return "Юридический блок"; if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно"; if (path.startsWith("content.features.")) return "Пункты"; if (path.startsWith("content.sliderItems.")) 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.startsWith("content.galleryItems.")) return "Медиа-галерея"; if (path.startsWith("content.files.")) return "TXT-файлы"; 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: "#hero", 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 ssscript", iconClass: "logo", dividerBefore: false, }, { enabled: true, title: "Корзина", href: "", linkTarget: "", targetId: "", iconSrc: "./assets/webflow/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]; 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: item.iconSrc || fallback.iconSrc, iconAlt: item.iconAlt || item.alt || fallback.iconAlt, className: item.className || fallback.className || "dock-icon ssscript", 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); state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications); state.page.staticElements.content.notifications.forEach((notification, index) => { notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray"; }); state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets); 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 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|avif|webp)(\?.*)?$/i.test(value)) return "image"; return 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"; return `${root}/${blockSegment}`; } function blockIcon(block) { const id = block.id || ""; const type = block.type || ""; if (id === "static-elements") return "SE"; 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 === "beforeCViz") return "Главная"; if (region.id === "cViz") return "Секции"; 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() { return state.templates.map((template) => { const regionIndex = state.page.regions.findIndex((region) => region.id === template.regionId); const region = state.page.regions[regionIndex] || state.page.regions[0]; return { ...template, regionIndex: regionIndex >= 0 ? regionIndex : 0, regionLabel: template.regionLabel || labelForRegion(region), }; }); } 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 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; block.scopeIds = true; 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.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 renderEditor() { const region = activeRegion(); const block = activeBlock(); const isStatic = isStaticSelection(); if (!block) { el.empty.classList.remove("hidden"); el.form.classList.add("hidden"); el.selectedRegion.textContent = "NODE.DC / Выберите блок"; el.selectedTitle.textContent = "Контентный слой"; return; } el.empty.classList.add("hidden"); el.form.classList.remove("hidden"); el.form.dataset.static = String(isStatic); el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`; el.selectedTitle.textContent = 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.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); try { renderContentFields(block); } catch (error) { renderEditorError(error); } el.json.value = JSON.stringify(block, null, 2); } function renderAll() { try { 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 = el.anchor.value.trim() || null; block.enabled = el.enabled.checked; } 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, file }) { if (!file) return; state.mediaUploadActive = true; setFilePickerActive(true); recoverEditorSurface(); error.textContent = ""; fileName.textContent = "Сохраняем в assets..."; try { const dataUrl = await readFileAsDataUrl(file); const response = await fetch("/api/upload/asset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ fileName: file.name, mimeType: file.type || "application/octet-stream", bucket: uploadBucketForField(block, editableField), dataUrl, }), }); const payload = await response.json(); 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.`); } catch (uploadError) { error.textContent = uploadError.message; fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28); setStatus(`Ошибка загрузки: ${uploadError.message}`); } 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 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("pointerdown", () => { setFilePickerActive(true); scheduleFilePickerRecovery(); }); fileButton.addEventListener("click", () => { openMediaFilePicker({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, }); }); 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) return clone(editableField.itemTemplate); 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 syncFieldValue(block, path, input) { setByPath(block, path, input.type === "checkbox" ? input.checked : input.value); 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 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 === "boolean") { renderBooleanField({ 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 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", () => { items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField))); rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`); }); head.append(title, addButton); list.className = "collection-list"; items.forEach((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"); details.className = "collection-item-card"; if (index === 0 && items.length <= 8) details.open = true; 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.textContent = "⋮⋮"; dragHandle.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); }); dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender)); controls.append(dragHandle); [ ["↑", "Поднять", () => { if (index === 0) return; const [moved] = items.splice(index, 1); items.splice(index - 1, 0, moved); rerender("Порядок элементов коллекции изменён локально."); }], ["↓", "Опустить", () => { if (index >= items.length - 1) return; const [moved] = items.splice(index, 1); items.splice(index + 1, 0, moved); rerender("Порядок элементов коллекции изменён локально."); }], ["⧉", "Дублировать", () => { items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item)); rerender("Элемент коллекции продублирован локально."); }], ["⌫", "Удалить", () => { items.splice(index, 1); 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); button.textContent = symbol; button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); handler(); }); controls.append(button); }); 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 === "boolean") { renderBooleanField({ 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, input.type === "checkbox" ? input.checked : input.value); } } function syncBlockFromJson() { if (!state.selected) return false; try { const parsed = JSON.parse(el.json.value); 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; } } async function loadPage() { 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(); state.selected = { kind: "static" }; renderAll(); setDirty(false); setStatus("Модель home.json загружена."); } async function persistPage() { if (activeBlock()) syncBlockFromFields(); 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} — копия`; 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} — вставка`; pasted.scopeIds = true; region.blocks.splice(state.selected.blockIndex + 1, 0, pasted); state.selected.blockIndex += 1; renderAll(); markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`); } function deleteSelected() { if (isStaticSelection()) return; const region = activeRegion(); if (!region || state.selected.blockIndex == null) 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»."); } for (const input of [el.id, el.label, el.anchor, el.enabled]) { input.addEventListener("input", syncBlockFromFields); input.addEventListener("change", syncBlockFromFields); } el.json.addEventListener("blur", () => { if (state.filePickerActive || state.mediaUploadActive) { recoverEditorSurface(); return; } syncBlockFromJson(); }); el.addBlock.addEventListener("click", openTemplateModal); el.templateModalClose.addEventListener("click", closeTemplateModal); el.templateModalCancel.addEventListener("click", closeTemplateModal); el.templateModal.addEventListener("click", (event) => { if (event.target === el.templateModal) closeTemplateModal(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) { closeTemplateModal(); } }); el.reload.addEventListener("click", () => loadPage().catch((error) => setStatus(error.message))); el.save.addEventListener("click", () => savePage().catch((error) => setStatus(error.message))); el.render.addEventListener("click", () => renderHome().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); 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));