const state = {
page: null,
templates: [],
selected: null,
clipboard: null,
drag: null,
dragTarget: null,
dirty: false,
};
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: {
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.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", "Уведомления"),
],
),
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-панели: файл логотипа, 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";
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 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.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 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.dock ||= clone(STATIC_FALLBACK.content.dock);
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.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 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 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);
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();
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 renderMediaPreview(preview, value) {
preview.innerHTML = "";
const src = adminPreviewUrl(value);
const kind = mediaKindFromValue(value);
if (!src) {
preview.textContent = "FILE";
return;
}
if (kind === "video") {
const video = document.createElement("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");
image.src = src;
image.alt = "";
image.loading = "lazy";
image.addEventListener("error", () => {
preview.innerHTML = "";
preview.textContent = "FILE";
});
preview.append(image);
return;
}
preview.textContent = "FILE";
}
function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, 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);
if (activeBlock() === block) {
el.json.value = JSON.stringify(block, null, 2);
}
markDirty();
}
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 fileLabel = document.createElement("label");
const fileName = document.createElement("span");
const fileInput = document.createElement("input");
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 error = document.createElement("span");
const inputId = `media-${editableField.path.replace(/[^a-z0-9_-]+/gi, "-")}-${Math.random().toString(36).slice(2)}`;
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";
fileLabel.className = "service-media-file-button";
fileLabel.htmlFor = inputId;
fileLabel.textContent = "Выберите файл";
fileName.className = "service-media-file-name";
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
fileName.title = fileNameFromUrl(value);
fileInput.id = inputId;
fileInput.type = "file";
fileInput.accept = MEDIA_ACCEPT;
fileControl.append(fileLabel, fileName, fileInput);
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);
path.className = "field-path";
path.textContent = `${editableField.path} -> assets/uploads/${uploadBucketForField(block, editableField)}`;
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,
value: urlInput.value,
});
});
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file) return;
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,
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 {
fileInput.value = "";
}
});
setSource(source);
control.append(fileControl, urlInput, sourceSwitch, preview);
container.append(labelRow, hiddenInput, control, path, 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 renderCollectionScalarField({ block, field, 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 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;
setByPath(active, field.path, input.value);
el.json.value = JSON.stringify(active, null, 2);
markDirty();
});
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";
for (const editableField of fields) {
if (editableField.kind === "collection") {
renderCollectionField({ block, editableField, grid });
continue;
}
if (editableField.kind === "media") {
renderMediaField({ 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;
setByPath(active, editableField.path, input.value);
el.json.value = JSON.stringify(active, null, 2);
markDirty();
});
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();
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", 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);
loadPage().catch((error) => setStatus(error.message));