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 состояния формы.",
"Юридический блок": "Политики, права и нижний текст формы.",
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
Пункты: "Текстовые пункты внутри секции.",
Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.",
"Один клик": "Подблок про быстрое добавление и настройку компонентов.",
MCP: "Подблок про MCP-сервер и контекст Webflow.",
FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.",
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.",
Контент: "Остальные typed-поля выбранного блока.",
};
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 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.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.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));