NODEDC_SITE/admin/admin.js

4020 lines
146 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const ADMIN_LAYOUT_STORAGE_KEY = "nodedc-admin-layout-v1";
function readAdminLayoutState() {
try {
return JSON.parse(localStorage.getItem(ADMIN_LAYOUT_STORAGE_KEY) || "{}");
} catch {
return {};
}
}
function writeAdminLayoutState(nextLayout) {
try {
localStorage.setItem(ADMIN_LAYOUT_STORAGE_KEY, JSON.stringify(nextLayout));
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}
function updateAdminLayoutState(patch) {
writeAdminLayoutState({
...readAdminLayoutState(),
...patch,
});
}
function readCollectionOpenState() {
const collections = readAdminLayoutState().collections || {};
return new Map(
Object.entries(collections).map(([key, value]) => [key, new Set(Array.isArray(value) ? value : [])]),
);
}
function readLastMediaDirectory() {
return String(readAdminLayoutState().lastMediaDirectory || "").replace(/^\/+/, "");
}
const state = {
page: null,
templates: [],
selected: null,
clipboard: null,
drag: null,
dragTarget: null,
dirty: false,
filePickerActive: false,
mediaUploadActive: false,
filePickerResetTimer: null,
deleteConfirmResolve: null,
mediaActionResolve: null,
collectionOpenState: readCollectionOpenState(),
mediaLibrary: {
columns: [],
context: null,
contextMenu: null,
trashMode: false,
previousPath: "",
selectedPath: "",
selectedEntry: null,
selectedUrl: "",
root: "",
kind: "all",
query: "",
loaded: false,
dragEntryPath: "",
lastPath: readLastMediaDirectory(),
},
};
const SECTIONS_REGION_ID = "sections";
const SECTIONS_REGION_LABEL = "Секции";
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"),
deleteModal: document.querySelector("#delete-modal"),
deleteModalTitle: document.querySelector("#delete-modal-title"),
deleteModalBody: document.querySelector("#delete-modal-body"),
deleteModalCancel: document.querySelector("#delete-modal-cancel"),
deleteModalConfirm: document.querySelector("#delete-modal-confirm"),
mediaModal: document.querySelector("#media-modal"),
mediaModalTitle: document.querySelector("#media-modal-title"),
mediaModalSubtitle: document.querySelector("#media-modal-subtitle"),
mediaSiteSize: document.querySelector("#media-site-size"),
mediaModalClose: document.querySelector("#media-modal-close"),
mediaModalCancel: document.querySelector("#media-modal-cancel"),
mediaTrashToggle: document.querySelector("#media-trash-toggle"),
mediaSearch: document.querySelector("#media-search"),
mediaKindFilters: document.querySelector("#media-kind-filters"),
mediaRootFilters: document.querySelector("#media-root-filters"),
mediaUpload: document.querySelector("#media-upload"),
mediaRefresh: document.querySelector("#media-refresh"),
mediaDropZone: document.querySelector("#media-drop-zone"),
mediaBreadcrumbs: document.querySelector("#media-breadcrumbs"),
mediaList: document.querySelector("#media-list"),
mediaEmpty: document.querySelector("#media-empty"),
mediaPreview: document.querySelector("#media-preview"),
mediaPreviewName: document.querySelector("#media-preview-name"),
mediaPreviewPath: document.querySelector("#media-preview-path"),
mediaPreviewMeta: document.querySelector("#media-preview-meta"),
mediaPreviewUsage: document.querySelector("#media-preview-usage"),
mediaSelect: document.querySelector("#media-select"),
mediaDelete: document.querySelector("#media-delete"),
mediaContextMenu: document.querySelector("#media-context-menu"),
mediaActionModal: document.querySelector("#media-action-modal"),
mediaActionTitle: document.querySelector("#media-action-title"),
mediaActionBody: document.querySelector("#media-action-body"),
mediaActionLabel: document.querySelector("#media-action-label"),
mediaActionInput: document.querySelector("#media-action-input"),
mediaActionCancel: document.querySelector("#media-action-cancel"),
mediaActionConfirm: document.querySelector("#media-action-confirm"),
};
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: [
{ enabled: true, label: "О продукте", href: "", target: "index.html#features", linkMode: "target", linkTarget: "" },
{ enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" },
{
enabled: true,
label: "Документация",
href: "https://docs.ssscript.app/",
target: "",
linkMode: "href",
linkTarget: "_blank",
},
{ enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-wrap", linkMode: "target", linkTarget: "" },
],
},
notifications: [
{
enabled: true,
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
enabled: true,
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
},
{
enabled: true,
eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt",
title: "Инвайты в закрытую бету уже отправлены.",
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
},
],
dock: {
items: [
{
enabled: true,
title: "NODE.DC",
href: "javascript:void(0)",
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: "javascript:void(0)",
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",
faviconSvg: "./assets/uploads/favicon/icon-adaptive.svg",
faviconIco: "./assets/uploads/favicon/favicon.ico",
faviconAppleTouchIcon: "./assets/uploads/favicon/apple-touch-icon.png",
faviconPng192: "./assets/uploads/favicon/icon-192.png",
faviconPng512: "./assets/uploads/favicon/icon-512.png",
faviconManifest: "./assets/uploads/favicon/manifest.webmanifest.json",
},
},
editableFields: [
field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"),
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"),
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"),
collection(
"content.navigation.links",
"Кнопки шапки",
"Шапка: ссылки",
{ enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" },
[
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"),
],
),
collection(
"content.notifications",
"Уведомления",
"Уведомления",
{
enabled: true,
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: "javascript:void(0)",
linkTarget: "",
targetId: "",
iconSrc: "",
iconAlt: "",
className: "dock-icon ssscript",
iconClass: "",
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", "Модель и логотипы"),
field("content.assets.faviconSvg", "Favicon SVG", "media", "attr", "Модель и логотипы"),
field("content.assets.faviconIco", "Favicon ICO", "media", "attr", "Модель и логотипы"),
field("content.assets.faviconAppleTouchIcon", "Favicon Apple 180", "media", "attr", "Модель и логотипы"),
field("content.assets.faviconPng192", "Favicon PNG 192", "media", "attr", "Модель и логотипы"),
field("content.assets.faviconPng512", "Favicon PNG 512", "media", "attr", "Модель и логотипы"),
field("content.assets.faviconManifest", "Favicon manifest", "media", "attr", "Модель и логотипы"),
],
};
const GROUP_COPY = {
"Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.",
"Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.",
Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.",
Dock: "Нижняя системная панель: приложения, корзина, ссылки и подписи файлов.",
"Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы, favicon-комплект и alt-тексты.",
Заголовок: "Главный заголовок и вводный текст выбранной секции.",
"Верхний информационный блок": "Верхняя пара: длинный текст и крупный заголовок над медиаслайдером.",
"Нижний информационный блок": "Нижняя зеркальная пара: крупный заголовок и длинный текст под медиаслайдером.",
"Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.",
"Карточка доступа": "Единый компонент окна заявки: редактируется здесь и повторяется в нижнем блоке запроса DEMO.",
Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.",
"Юридический блок": "Политики, права и нижний текст формы.",
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
Пункты: "Текстовые пункты внутри секции.",
Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.",
Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.",
"Один клик": "Подблок про быстрое добавление и настройку компонентов.",
MCP: "Подблок про MCP-сервер и контекст Webflow.",
"Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.",
Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.",
FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.",
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
"Центральный текст": "Отдельный центрированный текстовый блок: надзаголовок, крупная строка и три подписи.",
Тарифы: "Окно тарифной таблицы: планы, цены, кнопки и примечания.",
"Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.",
"Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.",
"Карточки скриншотов": "Скриншоты в виде окон и файлов: каждый элемент можно скрыть, переставить, дублировать или удалить.",
"TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.",
Футер: "Брендовый блок, копирайт и базовые ассеты футера.",
"Навигация футера": "Ссылки навигационной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.",
"Контактные ссылки": "Ссылки контактной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.",
Политики: "Нижняя строка политик и условия отображения.",
Контент: "Остальные typed-поля выбранного блока.",
};
const MEDIA_ACCEPT =
"image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.ico,.avif,.webp,.webmanifest,.json,.woff,.woff2";
const MEDIA_DRAG_MIME = "application/x-nodedc-media-entry";
const MEDIA_KIND_FILTERS = [
["all", "Все"],
["image", "Изображения"],
["video", "Видео"],
["model", "3D"],
];
const MEDIA_ROOT_FILTERS = [
["", "Проект"],
["assets", "Assets"],
["assets/uploads", "Uploads"],
["assets/media", "Media"],
["assets/webflow/images", "Webflow"],
["assets/custom", "Custom"],
];
const FINDER_TRASH_ICON_SVG =
'<svg class="trash-can-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.75 6.75h16.5"></path><path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85"></path><path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15"></path><path d="M10 10.75v5.5"></path><path d="M14 10.75v5.5"></path></svg>';
const mediaFilePicker = createMediaFilePicker();
const collectionItemRuntimeKeys = new WeakMap();
let collectionItemRuntimeKeyCounter = 0;
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;
}
const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]);
function isWindowTarget(value) {
return LINK_WINDOW_TARGETS.has(String(value || "").trim());
}
function isExternalHref(value) {
return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || ""));
}
function normalizeChoiceLink(item, fallback = {}) {
if (!item || typeof item !== "object") return;
let href = item.href ?? fallback.href ?? "";
let target = item.target ?? fallback.target ?? "";
let linkTarget = item.linkTarget ?? fallback.linkTarget ?? "";
if (isWindowTarget(target)) {
linkTarget ||= target;
target = "";
}
const inferredMode = isExternalHref(href) ? "href" : "target";
const linkMode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : inferredMode;
if (linkMode === "target" && !target && href && !isExternalHref(href)) {
target = href;
href = "";
}
item.enabled = item.enabled !== false;
item.href = href;
item.target = target;
item.linkMode = linkMode;
item.linkTarget = linkTarget;
}
function normalizeChoiceLinks(items, fallbacks = []) {
if (!Array.isArray(items)) return;
items.forEach((item, index) => normalizeChoiceLink(item, fallbacks[index] || fallbacks[0] || {}));
}
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" || path.startsWith("content.legal.")) return "Юридический блок";
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
if (path.startsWith("content.features.")) return "Пункты";
if (path.startsWith("content.scroll.")) return "Скролл";
if (path.startsWith("content.sliderItems.")) return "Слайдер";
if (path.startsWith("content.bottomInfo.")) 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 === "content.galleryItems" || path.startsWith("content.galleryItems.")) return "Карточки скриншотов";
if (path === "content.filesEnabled" || path.startsWith("content.files.")) return "TXT-файлы";
if (path === "content.footer.policyEnabled" || path === "content.footer.policyHtml") return "Политики";
if (path.startsWith("content.footer.navLinks.")) return "Навигация футера";
if (path.startsWith("content.footer.contactLinks.")) return "Контактные ссылки";
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: "javascript:void(0)",
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: "javascript:void(0)",
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];
const hasOwn = (key) => Object.prototype.hasOwnProperty.call(item, key);
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: hasOwn("iconSrc") ? item.iconSrc : fallback.iconSrc,
iconAlt: hasOwn("iconAlt") ? item.iconAlt : item.alt || fallback.iconAlt,
className: item.className || fallback.className || "dock-icon ssscript",
iconClass: hasOwn("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);
normalizeChoiceLinks(state.page.staticElements.content.navigation.links, STATIC_FALLBACK.content.navigation.links);
state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications);
state.page.staticElements.content.notifications.forEach((notification, index) => {
notification.enabled = notification.enabled !== false;
notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray";
});
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets);
for (const [key, value] of Object.entries(STATIC_FALLBACK.content.assets)) {
if (!Object.prototype.hasOwnProperty.call(state.page.staticElements.content.assets, key)) {
state.page.staticElements.content.assets[key] = value;
}
}
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 ensureSectionsRegion() {
const regions = Array.isArray(state.page.regions) ? state.page.regions : [];
const blocks = regions.flatMap((region) =>
(region.blocks ?? []).map((block) => {
block.region ||= region.id;
return block;
}),
);
state.page.regions = [
{
id: SECTIONS_REGION_ID,
label: SECTIONS_REGION_LABEL,
description: "Единый порядок секций главной страницы.",
blocks,
},
];
}
function sectionsRegionIndex() {
return Math.max(
0,
state.page.regions.findIndex((region) => region.id === SECTIONS_REGION_ID),
);
}
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|ico|avif|webp)(\?.*)?$/i.test(value)) return "image";
if (/\.(glb|gltf)(\?.*)?$/i.test(value)) return "model";
if (/\.(woff2?|ttf|otf)(\?.*)?$/i.test(value)) return "font";
return null;
}
function formatFileSize(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value < 0) return "";
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB"];
let size = value / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`;
}
function formatFileDate(ms) {
const value = Number(ms);
if (!Number.isFinite(value)) return "";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function mediaUsageStatus(file) {
return file?.usage?.status || "unused";
}
function mediaUsageLabel(file) {
return file?.usage?.label || "Не используется";
}
function mediaFileMeta(file) {
return [file.kind, file.extension, formatFileSize(file.size)].filter(Boolean).join(" · ");
}
function selectedMediaFile() {
const entry = state.mediaLibrary.selectedEntry;
return entry?.type === "file" ? entry : 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";
if (path.includes("favicon")) return "favicon";
return `${root}/${blockSegment}`;
}
function blockIcon(block) {
const id = block.id || "";
const type = block.type || "";
if (id === "static-elements") return "SE";
if (id.includes("gallery") || id.includes("screenshot")) return "SC";
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 === SECTIONS_REGION_ID || region.id === "beforeCViz" || region.id === "cViz") return SECTIONS_REGION_LABEL;
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() {
const regionIndex = sectionsRegionIndex();
return state.templates.map((template) => {
return {
...template,
regionIndex,
regionLabel: SECTIONS_REGION_LABEL,
};
});
}
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 closeDeleteModal(confirmed = false) {
const resolve = state.deleteConfirmResolve;
state.deleteConfirmResolve = null;
el.deleteModal.classList.add("hidden");
if (resolve) resolve(confirmed);
}
function requestDeleteConfirmation({
title = "Подтвердите действие",
body = "Вы действительно подтвердить удаление элемента?",
} = {}) {
if (state.deleteConfirmResolve) closeDeleteModal(false);
return new Promise((resolve) => {
state.deleteConfirmResolve = resolve;
el.deleteModalTitle.textContent = title;
el.deleteModalBody.textContent = body;
el.deleteModal.classList.remove("hidden");
el.deleteModalConfirm.focus();
});
}
function closeMediaActionModal(value = null) {
const resolve = state.mediaActionResolve;
state.mediaActionResolve = null;
el.mediaActionModal.classList.add("hidden");
if (resolve) resolve(value);
}
function requestMediaActionInput({
title = "Действие",
body = "Введите имя.",
label = "Имя",
value = "",
confirmLabel = "Подтвердить",
} = {}) {
if (state.mediaActionResolve) closeMediaActionModal(null);
return new Promise((resolve) => {
state.mediaActionResolve = resolve;
el.mediaActionTitle.textContent = title;
el.mediaActionBody.textContent = body;
el.mediaActionLabel.textContent = label;
el.mediaActionConfirm.textContent = confirmLabel;
el.mediaActionInput.value = value;
el.mediaActionModal.classList.remove("hidden");
el.mediaActionInput.focus();
el.mediaActionInput.select();
});
}
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, afterUpload, file }) {
if (!file) return;
state.mediaUploadActive = true;
setFilePickerActive(true);
recoverEditorSurface();
error.textContent = "";
fileName.textContent = "Сохраняем в assets...";
try {
const formData = new FormData();
formData.append("file", file, file.name);
formData.append("fileName", file.name);
formData.append("mimeType", file.type || "application/octet-stream");
formData.append("bucket", uploadBucketForField(block, editableField));
const response = await fetch("/api/upload/asset", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => ({
ok: false,
error: `Сервер вернул ${response.status}`,
}));
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.`);
if (typeof afterUpload === "function") await afterUpload(payload);
return payload;
} catch (uploadError) {
error.textContent = uploadError.message;
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
setStatus(`Ошибка загрузки: ${uploadError.message}`);
return null;
} 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 renderMediaThumb(target, file, { large = false } = {}) {
target.innerHTML = "";
target.classList.remove("is-image", "is-video");
if (!file) {
target.textContent = "FILE";
return;
}
if (file.kind === "image") {
const image = document.createElement("img");
target.classList.add("is-image");
image.src = adminPreviewUrl(file.url);
image.alt = file.name || "";
image.loading = "lazy";
image.decoding = "async";
target.append(image);
return;
}
if (file.kind === "video") {
const video = document.createElement("video");
target.classList.add("is-video");
video.src = adminPreviewUrl(file.url);
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "metadata";
if (large) video.controls = true;
target.append(video);
return;
}
const label = file.kind === "model" ? "3D" : file.kind === "font" ? "FONT" : file.extension?.toUpperCase() || "FILE";
target.textContent = label;
}
function renderMediaFilterButtons() {
el.mediaKindFilters.innerHTML = "";
el.mediaRootFilters.innerHTML = "";
const activeRoot = activeMediaRootFilter();
for (const [kind, label] of MEDIA_KIND_FILTERS) {
const button = document.createElement("button");
button.type = "button";
button.className = "media-filter-button";
button.dataset.active = String(state.mediaLibrary.kind === kind);
button.disabled = state.mediaLibrary.trashMode;
button.textContent = label;
button.addEventListener("click", () => {
state.mediaLibrary.kind = kind;
renderMediaLibrary();
});
el.mediaKindFilters.append(button);
}
for (const [rootPath, label] of MEDIA_ROOT_FILTERS) {
const button = document.createElement("button");
button.type = "button";
button.className = "media-filter-button";
button.dataset.active = String(activeRoot === rootPath);
button.disabled = state.mediaLibrary.trashMode;
button.textContent = label;
button.title = rootPath || "Корень проекта NODEDC_SITE";
button.addEventListener("click", async () => {
state.mediaLibrary.root = rootPath;
state.mediaLibrary.selectedEntry = null;
state.mediaLibrary.selectedUrl = "";
await loadMediaColumns(rootPath).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`));
});
el.mediaRootFilters.append(button);
}
}
function mediaPathParts(path) {
return String(path || "")
.split("/")
.filter(Boolean);
}
function isSameOrChildPath(path, rootPath) {
if (!rootPath) return !path;
return path === rootPath || path.startsWith(`${rootPath}/`);
}
function activeMediaRootFilter() {
const selectedPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || "";
let activeRoot = "";
for (const [rootPath] of MEDIA_ROOT_FILTERS) {
if (rootPath && isSameOrChildPath(selectedPath, rootPath)) {
activeRoot = rootPath;
}
}
return activeRoot;
}
function mediaPathChain(path) {
const parts = mediaPathParts(path);
const chain = [""];
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
chain.push(current);
}
return chain;
}
function parentMediaPath(path) {
const parts = mediaPathParts(path);
parts.pop();
return parts.join("/");
}
function rememberMediaDirectory(path) {
const nextPath = String(path || "").replace(/^\/+/, "");
if (nextPath === "__trash__") return;
state.mediaLibrary.lastPath = nextPath;
updateAdminLayoutState({ lastMediaDirectory: nextPath });
}
function pathFromUrl(url) {
let value = String(url || "").split("?")[0].split("#")[0];
if (value.startsWith("./")) value = value.slice(2);
try {
value = decodeURIComponent(value);
} catch {
// Keep raw value for malformed escape sequences.
}
return value;
}
function mediaReplacementVariants(pathOrUrl) {
const path = pathFromUrl(pathOrUrl);
if (!path) return [];
const encodedPath = path
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
const variants = [path, `./${path}`, `/${path}`, encodedPath, `./${encodedPath}`, `/${encodedPath}`];
return [...new Set(variants)].sort((left, right) => right.length - left.length);
}
function replaceMediaReferencesInString(value, replacements) {
let nextValue = value;
for (const replacement of replacements) {
const fromVariants = mediaReplacementVariants(replacement.fromUrl || replacement.from);
const toPath = pathFromUrl(replacement.toUrl || replacement.to);
if (!fromVariants.length || !toPath) continue;
const encodedToPath = toPath
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
for (const fromVariant of fromVariants) {
const hasDotPrefix = fromVariant.startsWith("./");
const hasRootPrefix = fromVariant.startsWith("/");
const toVariant = `${hasDotPrefix ? "./" : hasRootPrefix ? "/" : ""}${fromVariant.includes("%") ? encodedToPath : toPath}`;
nextValue = nextValue.split(fromVariant).join(toVariant);
}
}
return nextValue;
}
function replaceMediaReferencesInValue(value, replacements) {
if (typeof value === "string") {
return replaceMediaReferencesInString(value, replacements);
}
if (Array.isArray(value)) {
let changed = false;
const nextValue = value.map((item) => {
const nextItem = replaceMediaReferencesInValue(item, replacements);
changed ||= nextItem !== item;
return nextItem;
});
return changed ? nextValue : value;
}
if (value && typeof value === "object") {
let changed = false;
const nextValue = { ...value };
for (const [key, item] of Object.entries(value)) {
const nextItem = replaceMediaReferencesInValue(item, replacements);
if (nextItem !== item) {
nextValue[key] = nextItem;
changed = true;
}
}
return changed ? nextValue : value;
}
return value;
}
async function browseMediaPath(path) {
const response = await fetch(`/api/media/browse?path=${encodeURIComponent(path || "")}`);
const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "Не удалось открыть папку");
}
return payload;
}
async function refreshMediaSiteSize() {
if (!el.mediaSiteSize) return;
el.mediaSiteSize.textContent = "Сайт: считаем...";
const response = await fetch("/api/media/site-size");
const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
if (!response.ok || !payload.ok) {
el.mediaSiteSize.textContent = "Сайт: --";
throw new Error(payload.error || "Не удалось посчитать размер сайта");
}
el.mediaSiteSize.textContent = `Сайт: ${payload.label}`;
el.mediaSiteSize.title = `${Number(payload.bytes || 0).toLocaleString("ru-RU")} байт`;
}
async function postMediaAction(endpoint, payload = {}) {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
if (!response.ok || !result.ok) {
throw new Error(result.error || "Операция не выполнена");
}
return result;
}
function mediaDragHasEntry(event) {
return Array.from(event.dataTransfer?.types || []).includes(MEDIA_DRAG_MIME);
}
function mediaDragPayload(event) {
const rawPayload = event.dataTransfer?.getData(MEDIA_DRAG_MIME);
if (!rawPayload) return null;
try {
return JSON.parse(rawPayload);
} catch {
return null;
}
}
function syncMediaFieldValueFromServer(context, value) {
if (!context || !value) return;
const { block, editableField, hiddenInput, urlInput, fileName, preview, hint } = context;
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);
}
context.setSource("file");
}
function applyMediaReferenceReplacements(replacements = []) {
const context = state.mediaLibrary.context;
if (!Array.isArray(replacements) || !replacements.length) return false;
let changed = false;
const contextPath = context?.hiddenInput ? pathFromUrl(context.hiddenInput.value || "") : "";
const nextPage = replaceMediaReferencesInValue(state.page, replacements);
if (nextPage !== state.page) {
state.page = nextPage;
changed = true;
}
document.querySelectorAll("input.service-media-hidden-input").forEach((input) => {
const nextValue = replaceMediaReferencesInString(input.value || "", replacements);
if (nextValue === input.value) return;
input.value = nextValue;
const field = input.closest(".service-media-field");
const fileName = field?.querySelector(".service-media-file-name");
const urlInput = field?.querySelector(".service-media-url-input");
if (urlInput) urlInput.value = nextValue;
if (fileName) {
fileName.textContent = truncateText(fileNameFromUrl(nextValue), 28);
fileName.title = fileNameFromUrl(nextValue);
}
changed = true;
});
if (activeBlock()) {
el.json.value = JSON.stringify(activeBlock(), null, 2);
}
if (!context?.hiddenInput) return changed;
const match = replacements.find((replacement) => pathFromUrl(replacement.fromUrl || replacement.from || "") === contextPath);
if (!match?.toUrl) return changed;
syncMediaFieldValueFromServer(context, match.toUrl);
return true;
}
function canDropMediaEntryOnTarget(entry, targetPath) {
if (!entry || state.mediaLibrary.trashMode) return false;
if (!entry.path) return false;
const normalizedTarget = String(targetPath || "").replace(/^\/+/, "");
const sourceParent = parentMediaPath(entry.path);
if (entry.type === "directory" && (normalizedTarget === entry.path || normalizedTarget.startsWith(`${entry.path}/`))) {
return false;
}
return normalizedTarget !== sourceParent;
}
function setMediaDropActive(node, active) {
if (!node) return;
if (active) {
node.dataset.dropActive = "true";
return;
}
delete node.dataset.dropActive;
}
function handleMediaDragOver(event, targetPath) {
if (!mediaDragHasEntry(event)) return;
const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath);
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = canDropMediaEntryOnTarget(entry, targetPath) ? "move" : "none";
setMediaDropActive(event.currentTarget, event.dataTransfer.dropEffect === "move");
}
function handleMediaDragLeave(event) {
if (event.currentTarget.contains(event.relatedTarget)) return;
setMediaDropActive(event.currentTarget, false);
}
async function moveMediaEntryToPath(entry, targetPath) {
if (!canDropMediaEntryOnTarget(entry, targetPath)) return;
const payload = await postMediaAction("/api/media/move", { path: entry.path, targetPath });
applyMediaReferenceReplacements(payload.replacements);
const nextParentPath = parentMediaPath(payload.path);
await loadMediaColumns(nextParentPath, { selectedUrl: payload.url || "" });
const movedEntry = findEntryByPath(payload.path);
if (movedEntry) {
state.mediaLibrary.selectedPath = nextParentPath;
state.mediaLibrary.selectedEntry = movedEntry;
state.mediaLibrary.selectedUrl = movedEntry.type === "file" ? movedEntry.url || "" : "";
renderMediaLibrary();
}
const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0;
setStatus(`Перенесено: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`);
}
function handleMediaDrop(event, targetPath) {
if (!mediaDragHasEntry(event)) return;
const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath);
event.preventDefault();
event.stopPropagation();
setMediaDropActive(event.currentTarget, false);
if (!canDropMediaEntryOnTarget(entry, targetPath)) return;
moveMediaEntryToPath(entry, targetPath).catch((error) => setStatus(`Ошибка переноса: ${error.message}`));
}
async function browseMediaTrash() {
const response = await fetch("/api/media/trash");
const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "Не удалось открыть удалённые");
}
return {
ok: true,
path: "__trash__",
breadcrumbs: [{ name: "Удалённые", path: "__trash__" }],
entries: payload.files || [],
};
}
function entryMatchesMediaFilters(entry) {
const query = state.mediaLibrary.query.trim().toLowerCase();
if (query) {
const matchesQuery = [entry.name, entry.path, entry.url, mediaUsageLabel(entry)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
if (!matchesQuery) return false;
}
if (entry.type === "directory") return true;
if (state.mediaLibrary.kind === "all") return true;
return entry.kind === state.mediaLibrary.kind;
}
function findEntryByUrl(url) {
for (const column of state.mediaLibrary.columns) {
const match = column.entries.find((entry) => entry.url === url);
if (match) return match;
}
return null;
}
function findEntryByPath(path) {
if (!path) return null;
for (const column of state.mediaLibrary.columns) {
const match = column.entries.find((entry) => entry.path === path);
if (match) return match;
}
return null;
}
function canDeleteMediaEntry(entry) {
if (!entry || state.mediaLibrary.trashMode) return false;
if (entry.type === "directory") return entry.path?.startsWith("assets/") && !entry.path.split("/").includes(".trash");
const isCurrentFieldValue = state.mediaLibrary.context?.hiddenInput?.value === entry.url;
return entry.type === "file" && entry.writable && mediaUsageStatus(entry) === "unused" && !isCurrentFieldValue;
}
function selectMediaEntry(entry, columnPath = state.mediaLibrary.selectedPath) {
if (!entry) return;
if (entry.type === "file") {
state.mediaLibrary.selectedPath = columnPath;
state.mediaLibrary.selectedUrl = entry.url || "";
} else {
state.mediaLibrary.selectedUrl = "";
}
state.mediaLibrary.selectedEntry = entry;
renderMediaLibrary();
}
function closeMediaContextMenu() {
el.mediaContextMenu.classList.add("hidden");
el.mediaContextMenu.innerHTML = "";
state.mediaLibrary.contextMenu = null;
}
function appendMediaContextAction(label, action, { disabled = false } = {}) {
const button = document.createElement("button");
button.type = "button";
button.setAttribute("role", "menuitem");
button.textContent = label;
button.disabled = disabled;
button.addEventListener("click", () => {
closeMediaContextMenu();
action();
});
el.mediaContextMenu.append(button);
}
function openMediaContextMenu(event, { entry = null, columnPath = state.mediaLibrary.selectedPath } = {}) {
event.preventDefault();
closeMediaContextMenu();
state.mediaLibrary.contextMenu = { entry, columnPath };
if (entry) {
selectMediaEntry(entry, columnPath);
}
if (state.mediaLibrary.trashMode) {
if (entry?.type === "file") {
appendMediaContextAction("Восстановить", () => restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`)));
appendMediaContextAction("Удалить навсегда", () => deleteSelectedTrashFile().catch((error) => setStatus(`Ошибка удаления из корзины: ${error.message}`)));
}
} else if (entry?.type === "file" || entry?.type === "directory") {
appendMediaContextAction("Дублировать", () => duplicateMediaEntry(entry).catch((error) => setStatus(`Ошибка дублирования: ${error.message}`)));
appendMediaContextAction("Переименовать", () => renameMediaEntry(entry).catch((error) => setStatus(`Ошибка переименования: ${error.message}`)));
appendMediaContextAction("Удалить", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)), {
disabled: !canDeleteMediaEntry(entry),
});
} else {
appendMediaContextAction("Создать папку", () => createMediaFolderAt(columnPath).catch((error) => setStatus(`Ошибка создания папки: ${error.message}`)));
}
if (!el.mediaContextMenu.children.length) return;
el.mediaContextMenu.classList.remove("hidden");
const rect = el.mediaContextMenu.getBoundingClientRect();
const x = Math.min(event.clientX, window.innerWidth - rect.width - 12);
const y = Math.min(event.clientY, window.innerHeight - rect.height - 12);
el.mediaContextMenu.style.left = `${Math.max(12, x)}px`;
el.mediaContextMenu.style.top = `${Math.max(12, y)}px`;
}
function scrollMediaBrowserToSelection() {
window.requestAnimationFrame(() => {
const columns = [...el.mediaList.querySelectorAll(".media-column")];
const targetColumn = columns.at(-1);
const exactEntry = el.mediaList.querySelector('.media-entry[data-exact-active="true"]');
const activeEntry = exactEntry || el.mediaList.querySelector('.media-entry[data-active="true"]');
if (targetColumn) {
const padding = 14;
const maxScrollLeft = Math.max(0, el.mediaList.scrollWidth - el.mediaList.clientWidth);
const targetRight = targetColumn.offsetLeft + targetColumn.offsetWidth + padding;
const targetLeft = targetColumn.offsetLeft - padding;
let nextScrollLeft = el.mediaList.scrollLeft;
if (targetRight > nextScrollLeft + el.mediaList.clientWidth) {
nextScrollLeft = targetRight - el.mediaList.clientWidth;
}
if (targetLeft < nextScrollLeft) {
nextScrollLeft = targetLeft;
}
el.mediaList.scrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft);
}
if (activeEntry) {
const list = activeEntry.closest(".media-column-list");
if (!list) return;
const padding = 8;
const nextSlotHeight = activeEntry.offsetHeight + 4;
const targetBottom = activeEntry.offsetTop + activeEntry.offsetHeight + nextSlotHeight + padding;
const targetTop = activeEntry.offsetTop - padding;
const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight);
let nextScrollTop = list.scrollTop;
if (targetBottom > nextScrollTop + list.clientHeight) {
nextScrollTop = targetBottom - list.clientHeight;
}
if (targetTop < nextScrollTop) {
nextScrollTop = targetTop;
}
list.scrollTop = Math.min(Math.max(nextScrollTop, 0), maxScrollTop);
}
});
}
async function loadMediaColumns(targetPath, { selectedUrl = state.mediaLibrary.selectedUrl } = {}) {
const path = String(targetPath || "").replace(/^\/+/, "");
const chain = mediaPathChain(path);
const columns = [];
state.mediaLibrary.trashMode = false;
setStatus("Открываем файловую структуру...");
for (const columnPath of chain) {
columns.push(await browseMediaPath(columnPath));
}
state.mediaLibrary.columns = columns;
state.mediaLibrary.selectedPath = path;
state.mediaLibrary.loaded = true;
state.mediaLibrary.selectedUrl = selectedUrl || "";
state.mediaLibrary.selectedEntry = selectedUrl ? findEntryByUrl(selectedUrl) : findEntryByPath(path);
rememberMediaDirectory(path);
renderMediaLibrary();
setStatus(`Файловая структура: ${path || "корень проекта"}.`);
}
async function loadMediaTrash({ selectedTrashPath = "" } = {}) {
setStatus("Открываем удалённые файлы...");
const column = await browseMediaTrash();
state.mediaLibrary.trashMode = true;
state.mediaLibrary.columns = [column];
state.mediaLibrary.selectedPath = "__trash__";
state.mediaLibrary.selectedUrl = "";
state.mediaLibrary.loaded = true;
state.mediaLibrary.selectedEntry = selectedTrashPath ? findEntryByPath(selectedTrashPath) : column.entries[0] || null;
renderMediaLibrary();
setStatus(`Удалённые файлы: ${column.entries.length}.`);
}
function renderMediaBreadcrumbs() {
el.mediaBreadcrumbs.innerHTML = "";
const breadcrumbs = state.mediaLibrary.columns.at(-1)?.breadcrumbs || [{ name: "NODEDC_SITE", path: "" }];
breadcrumbs.forEach((crumb, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "media-breadcrumb-button";
button.textContent = crumb.name;
button.dataset.active = String(index === breadcrumbs.length - 1);
button.addEventListener("click", () => {
state.mediaLibrary.root = crumb.path;
state.mediaLibrary.selectedEntry = null;
state.mediaLibrary.selectedUrl = "";
loadMediaColumns(crumb.path).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`));
});
el.mediaBreadcrumbs.append(button);
});
}
function renderMediaEntry(entry, columnPath) {
const button = document.createElement("button");
const icon = document.createElement("span");
const copy = document.createElement("span");
const name = document.createElement("span");
const meta = document.createElement("span");
const statusDot = document.createElement("span");
const arrow = document.createElement("span");
const isExactSelected = state.mediaLibrary.selectedEntry?.path === entry.path;
const isSelected =
isExactSelected ||
(entry.type === "directory" && isSameOrChildPath(state.mediaLibrary.selectedPath, entry.path));
button.type = "button";
button.className = "media-entry";
button.dataset.type = entry.type;
button.dataset.path = entry.path;
button.dataset.active = String(isSelected);
button.dataset.exactActive = String(isExactSelected);
button.title = entry.url || entry.path;
button.draggable = !state.mediaLibrary.trashMode;
icon.className = "media-entry-icon";
if (entry.type === "file" && entry.url && entry.kind === "image") {
const image = document.createElement("img");
icon.classList.add("is-preview");
image.src = adminPreviewUrl(entry.url);
image.alt = "";
image.loading = "lazy";
image.decoding = "async";
icon.append(image);
} else if (entry.type === "file" && entry.url && entry.kind === "video") {
const video = document.createElement("video");
icon.classList.add("is-preview");
video.src = adminPreviewUrl(entry.url);
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
icon.append(video);
} else {
icon.textContent =
entry.type === "directory"
? "DIR"
: entry.kind === "model"
? "3D"
: entry.kind === "font"
? "Aa"
: entry.extension?.toUpperCase() || "FILE";
}
copy.className = "media-entry-copy";
name.className = "media-entry-name";
name.textContent = entry.name;
meta.className = "media-entry-meta";
meta.textContent =
entry.type === "directory"
? entry.path || "Корень проекта"
: [mediaUsageLabel(entry), entry.extension, formatFileSize(entry.size)].filter(Boolean).join(" · ");
copy.append(name, meta);
statusDot.className = "media-status-dot media-entry-status";
statusDot.dataset.status = mediaUsageStatus(entry);
statusDot.title = mediaUsageLabel(entry);
arrow.className = "media-entry-arrow";
arrow.textContent = entry.type === "directory" ? "" : "";
button.append(icon, copy);
if (entry.type === "file") button.append(statusDot);
button.append(arrow);
button.addEventListener("click", async () => {
if (entry.type === "directory") {
state.mediaLibrary.root = entry.path;
state.mediaLibrary.selectedEntry = entry;
state.mediaLibrary.selectedUrl = "";
await loadMediaColumns(entry.path);
return;
}
selectMediaEntry(entry, columnPath);
});
button.addEventListener("contextmenu", (event) => {
event.stopPropagation();
openMediaContextMenu(event, { entry, columnPath });
});
button.addEventListener("dblclick", () => {
if (entry.type === "file" && entry.selectable) chooseSelectedMediaFile();
});
button.addEventListener("dragstart", (event) => {
if (state.mediaLibrary.trashMode) {
event.preventDefault();
return;
}
const payload = JSON.stringify({ path: entry.path, type: entry.type });
state.mediaLibrary.dragEntryPath = entry.path;
button.dataset.dragging = "true";
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(MEDIA_DRAG_MIME, payload);
event.dataTransfer.setData("text/plain", entry.path);
});
button.addEventListener("dragend", () => {
state.mediaLibrary.dragEntryPath = "";
delete button.dataset.dragging;
el.mediaList.querySelectorAll("[data-drop-active]").forEach((node) => setMediaDropActive(node, false));
});
if (entry.type === "directory") {
button.addEventListener("dragover", (event) => handleMediaDragOver(event, entry.path));
button.addEventListener("dragleave", handleMediaDragLeave);
button.addEventListener("drop", (event) => handleMediaDrop(event, entry.path));
}
return button;
}
function renderMediaColumn(column) {
const columnNode = document.createElement("section");
const head = document.createElement("div");
const title = document.createElement("div");
const count = document.createElement("div");
const list = document.createElement("div");
const entries = (column.entries || []).filter(entryMatchesMediaFilters);
columnNode.className = "media-column";
head.className = "media-column-head";
title.className = "media-column-title";
title.textContent = column.path ? mediaPathParts(column.path).at(-1) : "NODEDC_SITE";
count.className = "media-column-count";
count.textContent = `${entries.length}`;
head.append(title, count);
list.className = "media-column-list";
list.dataset.path = column.path || "";
list.addEventListener("dragover", (event) => handleMediaDragOver(event, column.path || ""));
list.addEventListener("dragleave", handleMediaDragLeave);
list.addEventListener("drop", (event) => handleMediaDrop(event, column.path || ""));
list.addEventListener("contextmenu", (event) => {
if (event.target.closest(".media-entry")) return;
openMediaContextMenu(event, { columnPath: column.path || "" });
});
for (const entry of entries) {
list.append(renderMediaEntry(entry, column.path));
}
if (!entries.length) {
const empty = document.createElement("div");
empty.className = "media-column-empty";
empty.textContent = "Пусто";
list.append(empty);
}
columnNode.append(head, list);
return columnNode;
}
function mediaColumnByPath(path) {
return state.mediaLibrary.columns.find((column) => column.path === path) || null;
}
function adjacentMediaFileUrl(file) {
const selectedPath = state.mediaLibrary.selectedPath || file?.directory || parentMediaPath(pathFromUrl(file?.url || ""));
const column = mediaColumnByPath(selectedPath);
if (!column) return "";
const visibleFiles = column.entries.filter(entryMatchesMediaFilters).filter((entry) => entry.type === "file");
let index = visibleFiles.findIndex((entry) => entry.path === file.path);
let adjacent = index >= 0 ? visibleFiles[index + 1] || visibleFiles[index - 1] : null;
if (!adjacent) {
const files = column.entries.filter((entry) => entry.type === "file");
index = files.findIndex((entry) => entry.path === file.path);
adjacent = index >= 0 ? files[index + 1] || files[index - 1] : null;
}
return adjacent?.url || "";
}
function adjacentMediaEntryPath(entry) {
if (!entry?.path) return "";
const column = mediaColumnForEntry(entry) || mediaColumnByPath(parentMediaPath(entry.path));
if (!column) return "";
const entries = visibleMediaEntries(column);
let index = entries.findIndex((candidate) => candidate.path === entry.path);
let adjacent = index >= 0 ? entries[index + 1] || entries[index - 1] : null;
if (!adjacent) {
index = column.entries.findIndex((candidate) => candidate.path === entry.path);
adjacent = index >= 0 ? column.entries[index + 1] || column.entries[index - 1] : null;
}
return adjacent?.path || "";
}
function visibleMediaEntries(column) {
return (column?.entries || []).filter(entryMatchesMediaFilters);
}
function mediaColumnForEntry(entry) {
if (!entry) return null;
return state.mediaLibrary.columns.find((column) => column.entries.some((candidate) => candidate.path === entry.path)) || null;
}
function activeMediaColumn() {
return (
mediaColumnForEntry(state.mediaLibrary.selectedEntry) ||
state.mediaLibrary.columns.find((column) => column.path === state.mediaLibrary.selectedPath) ||
state.mediaLibrary.columns.at(-1) ||
null
);
}
async function moveMediaSelection(delta) {
const column = activeMediaColumn();
const entries = visibleMediaEntries(column);
if (!column || !entries.length) return;
const currentIndex = entries.findIndex((entry) => entry.path === state.mediaLibrary.selectedEntry?.path);
const fallbackIndex = delta > 0 ? 0 : entries.length - 1;
const nextIndex = currentIndex < 0 ? fallbackIndex : Math.min(Math.max(currentIndex + delta, 0), entries.length - 1);
const nextEntry = entries[nextIndex];
if (nextEntry.type === "directory" && !state.mediaLibrary.trashMode) {
state.mediaLibrary.root = nextEntry.path;
state.mediaLibrary.selectedUrl = "";
await loadMediaColumns(nextEntry.path, { selectedUrl: "" });
const selectedDirectory = findEntryByPath(nextEntry.path);
if (selectedDirectory) {
state.mediaLibrary.selectedPath = column.path;
state.mediaLibrary.selectedEntry = selectedDirectory;
state.mediaLibrary.selectedUrl = "";
renderMediaLibrary();
}
return;
}
selectMediaEntry(nextEntry, column.path);
}
async function openSelectedMediaDirectory() {
const entry = state.mediaLibrary.selectedEntry;
if (!entry || entry.type !== "directory" || state.mediaLibrary.trashMode) return;
state.mediaLibrary.root = entry.path;
state.mediaLibrary.selectedEntry = entry;
state.mediaLibrary.selectedUrl = "";
await loadMediaColumns(entry.path, { selectedUrl: "" });
const openedColumn = mediaColumnByPath(entry.path);
const firstEntry = visibleMediaEntries(openedColumn)[0];
if (firstEntry) {
state.mediaLibrary.selectedPath = entry.path;
state.mediaLibrary.selectedEntry = firstEntry;
state.mediaLibrary.selectedUrl = firstEntry.type === "file" ? firstEntry.url || "" : "";
renderMediaLibrary();
}
}
async function moveMediaHierarchyBack() {
if (state.mediaLibrary.trashMode) return;
const path = state.mediaLibrary.selectedPath || state.mediaLibrary.selectedEntry?.path || "";
if (!path) return;
const parentPath = parentMediaPath(path);
state.mediaLibrary.selectedUrl = "";
await loadMediaColumns(parentPath, { selectedUrl: "" });
const closedDirectory = findEntryByPath(path);
if (closedDirectory) {
state.mediaLibrary.selectedPath = parentPath;
state.mediaLibrary.selectedEntry = closedDirectory;
state.mediaLibrary.selectedUrl = "";
renderMediaLibrary();
}
}
function isTypingTarget(target) {
return Boolean(target?.closest?.("input, textarea, select, [contenteditable='true']"));
}
function renderMediaPreviewPanel() {
const file = selectedMediaFile();
const entry = state.mediaLibrary.selectedEntry;
const usageText = el.mediaPreviewUsage.lastElementChild;
renderMediaThumb(el.mediaPreview, file, { large: true });
el.mediaSelect.textContent = state.mediaLibrary.trashMode ? "Восстановить" : "Выбрать";
el.mediaSelect.disabled = !file;
el.mediaDelete.disabled = true;
if (!entry) {
el.mediaPreviewName.textContent = "Выберите файл или папку";
el.mediaPreviewPath.textContent = state.mediaLibrary.selectedPath || "NODEDC_SITE";
el.mediaPreviewMeta.textContent = "Column view проекта";
el.mediaPreviewUsage.dataset.status = "idle";
if (usageText) usageText.textContent = "Нет выбора";
return;
}
if (entry.type === "directory") {
const canDeleteDirectory = canDeleteMediaEntry(entry);
el.mediaPreview.innerHTML = "<span>DIR</span>";
el.mediaPreviewName.textContent = entry.name;
el.mediaPreviewPath.textContent = entry.path || "NODEDC_SITE";
el.mediaPreviewPath.title = entry.path || "NODEDC_SITE";
el.mediaPreviewMeta.textContent = "Папка проекта";
el.mediaPreviewUsage.dataset.status = "idle";
if (usageText) usageText.textContent = "Папка";
el.mediaDelete.disabled = !canDeleteDirectory;
el.mediaDelete.title = canDeleteDirectory ? "Перенести папку в assets/.trash" : "Удаление доступно только для папок внутри assets";
return;
}
if (state.mediaLibrary.trashMode) {
el.mediaSelect.disabled = !entry.restorable;
el.mediaPreviewName.textContent = entry.originalName || entry.name;
el.mediaPreviewPath.textContent = entry.originalPath || entry.path;
el.mediaPreviewPath.title = entry.originalPath || entry.path;
el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · ");
el.mediaPreviewUsage.dataset.status = "trash";
if (usageText) usageText.textContent = "Удалён";
el.mediaDelete.disabled = !entry.trashPath;
el.mediaDelete.title = "Удалить выбранный файл из корзины навсегда";
return;
}
const canDelete = canDeleteMediaEntry(entry);
el.mediaSelect.disabled = !entry.selectable;
el.mediaPreviewName.textContent = entry.name;
el.mediaPreviewPath.textContent = entry.url || entry.path;
el.mediaPreviewPath.title = entry.url || entry.path;
el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · ");
el.mediaPreviewUsage.dataset.status = mediaUsageStatus(entry);
if (usageText) usageText.textContent = entry.selectable ? mediaUsageLabel(entry) : "Не публичный asset";
el.mediaDelete.disabled = !canDelete;
el.mediaDelete.title = canDelete
? "Перенести unused-файл в assets/.trash"
: "Удаление доступно только для unused-файлов из assets";
}
function renderMediaLibrary() {
el.mediaTrashToggle.dataset.active = String(state.mediaLibrary.trashMode);
el.mediaUpload.textContent = state.mediaLibrary.trashMode ? "Очистить корзину" : "+ Загрузить";
renderMediaFilterButtons();
renderMediaBreadcrumbs();
el.mediaList.innerHTML = "";
el.mediaList.classList.add("media-columns");
const columns = state.mediaLibrary.columns || [];
el.mediaEmpty.classList.toggle("hidden", columns.length > 0);
for (const column of columns) {
el.mediaList.append(renderMediaColumn(column));
}
renderMediaPreviewPanel();
scrollMediaBrowserToSelection();
}
async function refreshMediaLibrary() {
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
if (state.mediaLibrary.trashMode) {
await loadMediaTrash({ selectedTrashPath: state.mediaLibrary.selectedEntry?.trashPath || "" });
return;
}
await loadMediaColumns(state.mediaLibrary.selectedPath || state.mediaLibrary.root || "");
}
async function toggleMediaTrash() {
if (state.mediaLibrary.trashMode) {
await loadMediaColumns(state.mediaLibrary.previousPath || state.mediaLibrary.root || "");
return;
}
state.mediaLibrary.previousPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || "";
state.mediaLibrary.selectedEntry = null;
state.mediaLibrary.selectedUrl = "";
await loadMediaTrash();
}
async function openMediaLibrary(context) {
const currentValuePath = pathFromUrl(context.hiddenInput.value || "");
const suggestedPath = `assets/uploads/${uploadBucketForField(context.block, context.editableField)}`;
const lastPath = state.mediaLibrary.lastPath || readLastMediaDirectory();
const startPath = lastPath || (currentValuePath ? parentMediaPath(currentValuePath) : suggestedPath);
state.mediaLibrary.context = context;
state.mediaLibrary.trashMode = false;
state.mediaLibrary.previousPath = "";
state.mediaLibrary.selectedUrl = context.hiddenInput.value || "";
state.mediaLibrary.selectedEntry = null;
state.mediaLibrary.selectedPath = startPath;
state.mediaLibrary.query = "";
state.mediaLibrary.root = startPath;
state.mediaLibrary.kind = "all";
el.mediaSearch.value = "";
el.mediaModalTitle.textContent = context.editableField.label || "Выбрать файл";
el.mediaModalSubtitle.textContent = `${context.editableField.path} -> ${suggestedPath}`;
el.mediaModal.classList.remove("hidden");
el.mediaSearch.focus();
refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
try {
await loadMediaColumns(startPath, { selectedUrl: context.hiddenInput.value || "" });
} catch (error) {
setStatus(`Ошибка медиатеки: ${error.message}`);
renderMediaLibrary();
}
}
function closeMediaLibrary() {
closeMediaContextMenu();
el.mediaModal.classList.add("hidden");
delete el.mediaDropZone.dataset.dragActive;
state.mediaLibrary.context = null;
state.mediaLibrary.trashMode = false;
state.mediaLibrary.selectedUrl = "";
state.mediaLibrary.selectedEntry = null;
recoverEditorSurface();
}
function chooseSelectedMediaFile() {
const file = selectedMediaFile();
const context = state.mediaLibrary.context;
if (!file || !context) return;
if (state.mediaLibrary.trashMode) {
restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`));
return;
}
updateMediaFieldValue({
...context,
value: file.url,
});
rememberMediaDirectory(file.directory || parentMediaPath(pathFromUrl(file.url)));
context.setSource("file");
closeMediaLibrary();
markDirty(`Выбран файл: ${file.url}. Нажмите «Сохранить модель», чтобы записать путь.`);
}
async function restoreSelectedMediaFile() {
const file = selectedMediaFile();
if (!file?.trashPath) return;
const payload = await postMediaAction("/api/media/restore", { trashPath: file.trashPath });
await loadMediaTrash();
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
setStatus(`Файл восстановлен: ${payload.path}.`);
}
async function clearMediaTrash() {
const confirmed = await requestDeleteConfirmation({
title: "Очистить корзину?",
body: "Все файлы из удалённых будут удалены окончательно.",
});
if (!confirmed) return;
await postMediaAction("/api/media/trash/clear");
await loadMediaTrash();
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
setStatus("Корзина очищена.");
}
async function createMediaFolderAt(path) {
if (state.mediaLibrary.trashMode) return;
const folderName = await requestMediaActionInput({
title: "Создать папку",
body: `Новая папка будет создана в ${path || "корне проекта"}.`,
label: "Название папки",
value: "new-folder",
confirmLabel: "Создать",
});
if (!folderName) return;
const payload = await postMediaAction("/api/media/folder", { path, name: folderName });
await loadMediaColumns(path);
state.mediaLibrary.selectedEntry = findEntryByPath(payload.path);
state.mediaLibrary.selectedUrl = "";
renderMediaLibrary();
setStatus(`Папка создана: ${payload.path}.`);
}
async function renameMediaEntry(entry) {
if (!entry || state.mediaLibrary.trashMode) return;
const nextName = await requestMediaActionInput({
title: "Переименовать",
body: entry.path,
label: "Новое имя",
value: entry.name,
confirmLabel: "Переименовать",
});
if (!nextName || nextName === entry.name) return;
const payload = await postMediaAction("/api/media/rename", { path: entry.path, name: nextName });
applyMediaReferenceReplacements(payload.replacements);
await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" });
const renamedEntry = findEntryByPath(payload.path);
if (renamedEntry) {
state.mediaLibrary.selectedPath = parentMediaPath(payload.path);
state.mediaLibrary.selectedEntry = renamedEntry;
state.mediaLibrary.selectedUrl = renamedEntry.type === "file" ? renamedEntry.url || "" : "";
renderMediaLibrary();
}
const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0;
setStatus(`Переименовано: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`);
}
async function duplicateMediaEntry(entry) {
if (!entry || state.mediaLibrary.trashMode) return;
const payload = await postMediaAction("/api/media/duplicate", { path: entry.path });
await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" });
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
if (!payload.url) {
state.mediaLibrary.selectedEntry = findEntryByPath(payload.path);
state.mediaLibrary.selectedUrl = "";
renderMediaLibrary();
}
setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} продублирован: ${payload.path}.`);
}
async function uploadIntoMediaLibrary(file) {
const context = state.mediaLibrary.context;
if (!context || !file) return;
await uploadMediaFile({
...context,
file,
afterUpload: async (payload) => {
state.mediaLibrary.selectedUrl = payload.url;
const uploadedPath = pathFromUrl(payload.url);
await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url });
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
},
});
}
function openMediaLibraryUploader() {
const context = state.mediaLibrary.context;
if (!context) return;
openMediaFilePicker({
...context,
afterUpload: async (payload) => {
state.mediaLibrary.selectedUrl = payload.url;
const uploadedPath = pathFromUrl(payload.url);
await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url });
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
},
});
}
async function deleteSelectedTrashFile() {
const file = selectedMediaFile();
if (!file?.trashPath) return;
const nextSelectedPath = adjacentMediaEntryPath(file);
const confirmed = await requestDeleteConfirmation({
title: "Удалить из корзины навсегда?",
body: "Этот файл будет удалён окончательно. Восстановить его через админку уже не получится.",
});
if (!confirmed) return;
await postMediaAction("/api/media/trash/delete", { trashPath: file.trashPath });
await loadMediaTrash({ selectedTrashPath: nextSelectedPath });
setStatus(`Файл удалён из корзины навсегда: ${file.originalPath || file.path}.`);
}
async function deleteSelectedMediaEntry() {
const entry = state.mediaLibrary.selectedEntry;
if (!entry || el.mediaDelete.disabled) return;
if (state.mediaLibrary.trashMode) {
await deleteSelectedTrashFile();
return;
}
const parentPath = entry.type === "file" ? state.mediaLibrary.selectedPath || parentMediaPath(pathFromUrl(entry.url)) : parentMediaPath(entry.path);
const nextSelectedPath = adjacentMediaEntryPath(entry);
const confirmed = await requestDeleteConfirmation({
title: entry.type === "directory" ? "Удалить папку?" : "Удалить unused-файл?",
body:
entry.type === "directory"
? "Папка будет перенесена в assets/.trash. Если внутри есть файлы, которые используются в модели или текущей верстке, сервер остановит удаление."
: "Файл будет перенесён в assets/.trash. Файлы, которые используются в модели или текущей верстке, сервер не удаляет.",
});
if (!confirmed) return;
const payload = await postMediaAction("/api/media/delete-entry", { path: entry.path });
await loadMediaColumns(parentPath, { selectedUrl: "" });
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
const nextEntry = nextSelectedPath ? findEntryByPath(nextSelectedPath) : null;
if (nextEntry) {
state.mediaLibrary.selectedPath = parentPath;
state.mediaLibrary.selectedEntry = nextEntry;
state.mediaLibrary.selectedUrl = nextEntry.type === "file" ? nextEntry.url || "" : "";
renderMediaLibrary();
}
setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} перенесён в ${payload.trashPath}.`);
}
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("click", () => {
openMediaLibrary({
block,
editableField,
hiddenInput,
urlInput,
fileName,
preview,
hint,
error,
setSource,
}).catch((errorMessage) => setStatus(`Ошибка медиатеки: ${errorMessage.message}`));
});
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) {
const item = clone(editableField.itemTemplate);
if (editableField.path === "content.dock.items") {
item.iconSrc = "";
item.iconAlt = "";
item.iconClass = "";
}
return item;
}
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 collectionInstanceKey(block, editableField) {
return `${block?.id || "static-elements"}::${editableField.path}`;
}
function stableCollectionPart(value) {
return String(value || "")
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase()
.slice(0, 80);
}
function collectionItemKey(item, index) {
if (item && typeof item === "object") {
if (item.id) return `id:${item.id}`;
const stableIdentity = item.targetId || item.href || "";
const stableIdentityPart = stableCollectionPart(stableIdentity);
if (stableIdentityPart) return `item:${stableIdentityPart}`;
const stableTitle = item.title || item.name || item.label || item.question || item.windowTitle || item.adminLabel || "";
const stableTitlePart = stableCollectionPart(stableTitle);
if (stableTitlePart) return `item:${stableTitlePart}`;
let runtimeKey = collectionItemRuntimeKeys.get(item);
if (!runtimeKey) {
collectionItemRuntimeKeyCounter += 1;
runtimeKey = `runtime:${collectionItemRuntimeKeyCounter}`;
collectionItemRuntimeKeys.set(item, runtimeKey);
}
return runtimeKey;
}
return `value:${index}:${String(item)}`;
}
function persistCollectionOpenState() {
const collections = {};
for (const [key, openKeys] of state.collectionOpenState.entries()) {
collections[key] = [...openKeys];
}
updateAdminLayoutState({ collections });
}
function collectionOpenKeys(block, editableField, items) {
const key = collectionInstanceKey(block, editableField);
let openKeys = state.collectionOpenState.get(key);
if (!openKeys) {
openKeys = new Set();
if (items.length > 0 && items.length <= 8) {
openKeys.add(collectionItemKey(items[0], 0));
}
state.collectionOpenState.set(key, openKeys);
persistCollectionOpenState();
}
return openKeys;
}
function fieldInputValue(input) {
if (input.type === "checkbox") return input.checked;
if (input.dataset.valueType === "number" || input.type === "range" || input.type === "number") {
const value = Number.parseFloat(input.value);
return Number.isFinite(value) ? value : 0;
}
return input.value;
}
function syncFieldValue(block, path, input) {
setByPath(block, path, fieldInputValue(input));
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 formatRangeValue(value, editableField) {
const step = Number.parseFloat(editableField.step ?? 1);
const precision = Number.isFinite(step) && step > 0 && step < 1 ? String(step).split(".")[1]?.length || 0 : 0;
return `${Number(value).toFixed(precision)}${editableField.suffix || ""}`;
}
function renderRangeField({ 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 control = document.createElement("span");
const input = document.createElement("input");
const output = document.createElement("span");
const min = Number.parseFloat(editableField.min ?? 0);
const max = Number.parseFloat(editableField.max ?? 1);
const step = Number.parseFloat(editableField.step ?? 0.01);
const rawValue = Number.parseFloat(getByPath(block, editableField.path));
const value = Number.isFinite(rawValue) ? rawValue : min;
label.className = "field-control range-field 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 || "range";
path.className = "field-path";
path.textContent = editableField.path;
control.className = "range-control";
output.className = "range-value";
labelRow.append(labelText, kind);
input.dataset.path = editableField.path;
input.dataset.valueType = "number";
input.type = "range";
input.min = Number.isFinite(min) ? String(min) : "0";
input.max = Number.isFinite(max) ? String(max) : "1";
input.step = Number.isFinite(step) ? String(step) : "0.01";
input.value = String(value);
output.textContent = formatRangeValue(value, editableField);
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
output.textContent = formatRangeValue(input.value, editableField);
syncFieldValue(active, editableField.path, input);
});
control.append(input, output);
label.append(labelRow, control, path);
grid.append(label);
}
function renderNumberField({ 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 control = document.createElement("span");
const input = document.createElement("input");
const suffix = document.createElement("span");
const rawValue = Number.parseFloat(getByPath(block, editableField.path));
const fallback = Number.parseFloat(editableField.defaultValue ?? editableField.min ?? 0);
const value = Number.isFinite(rawValue) ? rawValue : fallback;
label.className = "field-control number-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 || "number";
path.className = "field-path";
path.textContent = editableField.path;
control.className = "number-control";
suffix.className = "number-suffix";
suffix.textContent = editableField.suffix || "";
labelRow.append(labelText, kind);
input.dataset.path = editableField.path;
input.dataset.valueType = "number";
input.type = "number";
input.value = Number.isFinite(value) ? String(value) : "0";
input.autocomplete = "off";
if (editableField.min != null) input.min = String(editableField.min);
if (editableField.max != null) input.max = String(editableField.max);
if (editableField.step != null) input.step = String(editableField.step);
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
syncFieldValue(active, editableField.path, input);
});
control.append(input);
if (suffix.textContent) control.append(suffix);
label.append(labelRow, control, path);
grid.append(label);
}
function siblingFieldPath(path, siblingKey) {
const keys = path.split(".");
keys[keys.length - 1] = siblingKey;
return keys.join(".");
}
function renderLinkModeField({ block, field, grid }) {
const container = document.createElement("div");
const labelRow = document.createElement("div");
const labelText = document.createElement("span");
const kind = document.createElement("span");
const hiddenMode = document.createElement("input");
const rows = document.createElement("div");
const path = document.createElement("span");
const hrefPath = siblingFieldPath(field.path, field.hrefPath || "href");
const targetPath = siblingFieldPath(field.path, field.targetPath || "target");
const currentMode = getByPath(block, field.path) === "href" ? "href" : "target";
function createRow({ mode, title, inputPath, placeholder }) {
const row = document.createElement("label");
const check = document.createElement("input");
const caption = document.createElement("span");
const input = document.createElement("input");
row.className = "link-mode-row";
check.type = "checkbox";
check.checked = currentMode === mode;
check.setAttribute("aria-label", `${title}: использовать`);
caption.textContent = title;
input.dataset.path = inputPath;
input.type = "text";
input.autocomplete = "off";
input.placeholder = placeholder;
input.value = getByPath(block, inputPath) ?? "";
check.addEventListener("change", () => {
if (!check.checked) {
check.checked = true;
return;
}
const active = activeBlock();
if (!active) return;
hiddenMode.value = mode;
setByPath(active, field.path, mode);
rows.querySelectorAll('input[type="checkbox"]').forEach((candidate) => {
candidate.checked = candidate === check;
});
el.json.value = JSON.stringify(active, null, 2);
markDirty(mode === "href" ? "Активен внешний адрес." : "Активен Target.");
});
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
syncFieldValue(active, inputPath, input);
});
row.append(check, caption, input);
return row;
}
container.className = "field-control wide link-mode-field";
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = field.label || "Источник ссылки";
kind.className = "field-kind";
kind.textContent = "link";
labelRow.append(labelText, kind);
hiddenMode.type = "hidden";
hiddenMode.dataset.path = field.path;
hiddenMode.value = currentMode;
rows.className = "link-mode-grid";
rows.append(
createRow({ mode: "href", title: "Адрес", inputPath: hrefPath, placeholder: "https://..." }),
createRow({ mode: "target", title: "Target", inputPath: targetPath, placeholder: "index.html#section" }),
);
path.className = "field-path";
path.textContent = `${field.path} · ${hrefPath} · ${targetPath}`;
container.append(labelRow, hiddenMode, rows, path);
grid.append(container);
}
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 === "link-mode") {
renderLinkModeField({ block, field, grid });
return;
}
if (field.kind === "boolean") {
renderBooleanField({ block, editableField: field, grid });
return;
}
if (field.kind === "range") {
renderRangeField({ block, editableField: field, grid });
return;
}
if (field.kind === "number") {
renderNumberField({ 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 openKeys = collectionOpenKeys(block, editableField, items);
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", () => {
const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField));
items.push(nextItem);
openKeys.add(collectionItemKey(nextItem, items.length - 1));
persistCollectionOpenState();
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
});
head.append(title, addButton);
list.className = "collection-list";
items.forEach((item, index) => {
const itemKey = collectionItemKey(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");
const hasVisibilityToggle =
item &&
typeof item === "object" &&
(Object.prototype.hasOwnProperty.call(item, "enabled") ||
Object.prototype.hasOwnProperty.call(editableField.itemTemplate ?? {}, "enabled"));
details.className = "collection-item-card";
details.classList.toggle("is-hidden", item?.enabled === false);
details.open = openKeys.has(itemKey);
details.addEventListener("toggle", () => {
if (details.open) openKeys.add(itemKey);
else openKeys.delete(itemKey);
persistCollectionOpenState();
});
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.innerHTML =
'<svg viewBox="0 0 12 18" aria-hidden="true" focusable="false"><circle cx="4" cy="4" r="1.25"></circle><circle cx="8" cy="4" r="1.25"></circle><circle cx="4" cy="9" r="1.25"></circle><circle cx="8" cy="9" r="1.25"></circle><circle cx="4" cy="14" r="1.25"></circle><circle cx="8" cy="14" r="1.25"></circle></svg>';
dragHandle.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender));
if (hasVisibilityToggle) {
if (!Object.prototype.hasOwnProperty.call(item, "enabled")) item.enabled = true;
const visibilityToggle = document.createElement("span");
const visibilityInput = document.createElement("input");
const visibilityText = document.createElement("span");
visibilityToggle.className = "collection-visibility-toggle";
visibilityToggle.title = "Скрыть/показать";
visibilityToggle.setAttribute("aria-label", "Скрыть/показать");
visibilityInput.type = "checkbox";
visibilityInput.checked = item.enabled !== false;
visibilityText.textContent = "Показать";
visibilityToggle.addEventListener("click", (event) => event.stopPropagation());
visibilityInput.addEventListener("click", (event) => event.stopPropagation());
visibilityInput.addEventListener("change", () => {
item.enabled = visibilityInput.checked;
rerender(visibilityInput.checked ? "Элемент коллекции показан локально." : "Элемент коллекции скрыт локально.");
});
visibilityToggle.append(visibilityInput, visibilityText);
controls.append(visibilityToggle);
}
[
["↑", "Поднять", () => {
if (index === 0) return;
const [moved] = items.splice(index, 1);
items.splice(index - 1, 0, moved);
persistCollectionOpenState();
rerender("Порядок элементов коллекции изменён локально.");
}],
["↓", "Опустить", () => {
if (index >= items.length - 1) return;
const [moved] = items.splice(index, 1);
items.splice(index + 1, 0, moved);
persistCollectionOpenState();
rerender("Порядок элементов коллекции изменён локально.");
}],
["⧉", "Дублировать", () => {
const nextItem = prepareCollectionItemForInsert(items, item);
items.splice(index + 1, 0, nextItem);
if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1));
persistCollectionOpenState();
rerender("Элемент коллекции продублирован локально.");
}],
[FINDER_TRASH_ICON_SVG, "Удалить", async () => {
const confirmed = await requestDeleteConfirmation();
if (!confirmed) return;
openKeys.delete(itemKey);
items.splice(index, 1);
persistCollectionOpenState();
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);
if (titleText === "Удалить") {
button.innerHTML = symbol;
} else {
button.textContent = symbol;
}
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`));
});
controls.append(button);
});
controls.append(dragHandle);
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 === "link-mode") {
renderLinkModeField({ block, field: editableField, grid });
continue;
}
if (editableField.kind === "boolean") {
renderBooleanField({ block, editableField, grid });
continue;
}
if (editableField.kind === "range") {
renderRangeField({ block, editableField, grid });
continue;
}
if (editableField.kind === "number") {
renderNumberField({ 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, fieldInputValue(input));
}
}
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();
ensureSectionsRegion();
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}» вставлен локально.`);
}
async function deleteSelected() {
if (isStaticSelection()) return;
const region = activeRegion();
if (!region || state.selected.blockIndex == null) return;
const confirmed = await requestDeleteConfirmation();
if (!confirmed) 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();
});
el.deleteModalCancel.addEventListener("click", () => closeDeleteModal(false));
el.deleteModalConfirm.addEventListener("click", () => closeDeleteModal(true));
el.deleteModal.addEventListener("click", (event) => {
if (event.target === el.deleteModal) closeDeleteModal(false);
});
el.mediaActionCancel.addEventListener("click", () => closeMediaActionModal(null));
el.mediaActionConfirm.addEventListener("click", () => closeMediaActionModal(el.mediaActionInput.value.trim()));
el.mediaActionModal.addEventListener("click", (event) => {
if (event.target === el.mediaActionModal) closeMediaActionModal(null);
});
el.mediaModalClose.addEventListener("click", closeMediaLibrary);
el.mediaModalCancel.addEventListener("click", closeMediaLibrary);
el.mediaModal.addEventListener("click", (event) => {
if (event.target === el.mediaModal) closeMediaLibrary();
});
el.mediaSearch.addEventListener("input", () => {
state.mediaLibrary.query = el.mediaSearch.value;
renderMediaLibrary();
});
el.mediaRefresh.addEventListener("click", () => refreshMediaLibrary().catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)));
el.mediaUpload.addEventListener("click", () => {
if (state.mediaLibrary.trashMode) {
clearMediaTrash().catch((error) => setStatus(`Ошибка очистки корзины: ${error.message}`));
return;
}
openMediaLibraryUploader();
});
el.mediaSelect.addEventListener("click", chooseSelectedMediaFile);
el.mediaDelete.addEventListener("click", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)));
el.mediaTrashToggle.addEventListener("click", () => toggleMediaTrash().catch((error) => setStatus(`Ошибка корзины: ${error.message}`)));
el.mediaDropZone.addEventListener("dragover", (event) => {
if (!state.mediaLibrary.context) return;
if (mediaDragHasEntry(event)) return;
event.preventDefault();
el.mediaDropZone.dataset.dragActive = "true";
});
el.mediaDropZone.addEventListener("dragleave", (event) => {
if (el.mediaDropZone.contains(event.relatedTarget)) return;
delete el.mediaDropZone.dataset.dragActive;
});
el.mediaDropZone.addEventListener("drop", (event) => {
if (!state.mediaLibrary.context) return;
if (mediaDragHasEntry(event)) return;
event.preventDefault();
delete el.mediaDropZone.dataset.dragActive;
const file = event.dataTransfer?.files?.[0];
uploadIntoMediaLibrary(file).catch((error) => setStatus(`Ошибка загрузки: ${error.message}`));
});
document.addEventListener("click", (event) => {
if (el.mediaContextMenu.classList.contains("hidden")) return;
if (el.mediaContextMenu.contains(event.target)) return;
closeMediaContextMenu();
});
document.addEventListener("contextmenu", (event) => {
if (el.mediaModal.classList.contains("hidden")) return;
if (el.mediaModal.contains(event.target)) return;
closeMediaContextMenu();
});
document.addEventListener("keydown", (event) => {
if (!el.deleteModal.classList.contains("hidden")) {
if (event.key === "Enter") {
event.preventDefault();
closeDeleteModal(true);
}
if (event.key === "Escape") {
event.preventDefault();
closeDeleteModal(false);
}
return;
}
if (!el.mediaActionModal.classList.contains("hidden")) {
if (event.key === "Enter") {
event.preventDefault();
closeMediaActionModal(el.mediaActionInput.value.trim());
}
if (event.key === "Escape") {
event.preventDefault();
closeMediaActionModal(null);
}
return;
}
if (!el.mediaModal.classList.contains("hidden")) {
if (!el.mediaContextMenu.classList.contains("hidden") && event.key === "Escape") {
event.preventDefault();
closeMediaContextMenu();
return;
}
if (isTypingTarget(event.target)) return;
if (event.key === "ArrowDown") {
event.preventDefault();
moveMediaSelection(1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`));
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveMediaSelection(-1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`));
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
openSelectedMediaDirectory().catch((error) => setStatus(`Ошибка навигации: ${error.message}`));
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
moveMediaHierarchyBack().catch((error) => setStatus(`Ошибка навигации: ${error.message}`));
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
if (state.mediaLibrary.trashMode ? selectedMediaFile()?.trashPath : canDeleteMediaEntry(state.mediaLibrary.selectedEntry)) {
event.preventDefault();
deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`));
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMediaLibrary();
}
return;
}
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().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)));
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));