4020 lines
146 KiB
JavaScript
4020 lines
146 KiB
JavaScript
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));
|