1961 lines
69 KiB
JavaScript
1961 lines
69 KiB
JavaScript
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: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||
eyebrowClass: "red-txt gray",
|
||
title: "Открытая бета",
|
||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||
},
|
||
{
|
||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 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));
|