const state = { page: null, selected: null, clipboard: null, }; const el = { regions: document.querySelector("#regions"), reload: document.querySelector("#reload"), save: document.querySelector("#save"), render: document.querySelector("#render"), 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"), }; const STATIC_FALLBACK = { id: "static-elements", type: "staticElements", adminLabel: "Статичные элементы", enabled: true, content: { navigation: { 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', title: "Открытая бета", body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", }, { eyebrowHtml: 'ДАЛЕЕ ИЮЛЬ 2026', title: "Альфа для Founder-доступа", body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.", }, { eyebrowHtml: "СЕЙЧАС", title: "Инвайты в закрытую бету уже отправлены.", body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ", }, ], dock: { appTarget: "app-data", trashAlt: "Белая иконка корзины.", 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.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка"), field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка"), field("content.navigation.links.0.label", "Ссылка 1: текст", "text", "text", "Шапка"), field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr", "Шапка"), field("content.navigation.links.1.label", "Ссылка 2: текст", "text", "text", "Шапка"), field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr", "Шапка"), field("content.navigation.links.2.label", "Ссылка 3: текст", "text", "text", "Шапка"), field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr", "Шапка"), field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr", "Шапка"), field("content.navigation.links.3.label", "Ссылка 4: текст", "text", "text", "Шапка"), field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr", "Шапка"), field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html", "html", "Уведомления"), field("content.notifications.0.title", "Уведомление 1: заголовок", "text", "text", "Уведомления"), field("content.notifications.0.body", "Уведомление 1: текст", "textarea", "text", "Уведомления"), field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html", "html", "Уведомления"), field("content.notifications.1.title", "Уведомление 2: заголовок", "text", "text", "Уведомления"), field("content.notifications.1.body", "Уведомление 2: текст", "textarea", "text", "Уведомления"), field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html", "html", "Уведомления"), field("content.notifications.2.title", "Уведомление 3: заголовок", "text", "text", "Уведомления"), field("content.notifications.2.body", "Уведомление 3: текст", "textarea", "text", "Уведомления"), field("content.dock.appTarget", "Dock: ID приложения", "text", "text", "Dock"), field("content.dock.trashAlt", "Dock: alt корзины", "text", "text", "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-панели: бренд, ссылки и целевые адреса.", Уведомления: "Карточки уведомлений справа сверху. Порядок соответствует текущему stack в shell.", Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.", "Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.", Заголовок: "Главный заголовок и вводный текст выбранной секции.", "Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.", "Карточка раннего доступа": "Текст внутри центрального окна приложения.", Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.", "Юридический блок": "Политики, права и нижний текст формы.", "Видео-окно": "Окно демо-видео и подключенный медиа-файл.", Пункты: "Текстовые пункты внутри секции.", Контент: "Остальные typed-поля выбранного блока.", }; function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) { return { path, label, kind, renderAs, group: group || inferFieldGroup(path) }; } function setStatus(message) { el.status.textContent = message; } 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.")) 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.heading.") || path === "content.introHtml") return "Заголовок"; return "Контент"; } 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.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 labelForRegion(region) { if (region.id === "beforeCViz") return "Главная"; if (region.id === "cViz") return "Секции"; return region.label || region.id; } function renderBlockButton({ list, block, meta, active, disabled, onClick }) { const button = document.createElement("button"); const name = document.createElement("span"); const description = document.createElement("span"); button.type = "button"; button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`; name.className = "block-name"; name.textContent = block.adminLabel || block.id; description.className = "block-meta"; description.textContent = meta; button.append(name, description); button.addEventListener("click", onClick); 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: `${block.id} · ${block.template || block.type}`, active: isActive, disabled: block.enabled === false, onClick: () => { state.selected = { kind: "block", regionIndex, blockIndex }; renderAll(); }, }); }); regionNode.append(title, list); el.regions.append(regionNode); }); } function setStructuralControlsDisabled(disabled) { for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) { control.disabled = disabled; } } 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.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; setStructuralControlsDisabled(isStatic); renderContentFields(block); el.json.value = JSON.stringify(block, null, 2); } function renderAll() { renderRegions(); renderEditor(); } 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(); } 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 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"; for (const editableField of fields) { 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; setByPath(active, editableField.path, input.value); el.json.value = JSON.stringify(active, null, 2); }); 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.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(); setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл."); return true; } catch (error) { setStatus(`Ошибка JSON: ${error.message}`); return false; } } async function loadPage() { const response = await fetch("/api/page/home"); if (!response.ok) throw new Error(await response.text()); state.page = await response.json(); ensureStaticElements(); state.selected = { kind: "static" }; renderAll(); setStatus("Модель home.json загружена."); } async function savePage() { 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()); setStatus("content/pages/home.json сохранён."); } async function renderHome() { 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"); } setStatus(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(); } 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(); } 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(); } 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(); } for (const input of [el.id, el.label, el.anchor, el.enabled]) { input.addEventListener("input", syncBlockFromFields); input.addEventListener("change", syncBlockFromFields); } el.json.addEventListener("blur", syncBlockFromJson); 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); loadPage().catch((error) => setStatus(error.message));