DC_CMS/admin/admin.js

5878 lines
218 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const ADMIN_LAYOUT_STORAGE_KEY = "nodedc-admin-layout-v1";
const KNOWLEDGE_ROOT_ID = "__knowledge-root__";
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(/^\/+/, "");
}
function readAdminMode() {
const mode = readAdminLayoutState().mode;
return mode === "seo" || mode === "knowledge" ? mode : "content";
}
const state = {
mode: readAdminMode(),
page: null,
projectConfig: null,
projectConfigDirty: false,
knowledge: null,
knowledgeSelectedId: null,
knowledgeSavedSelection: null,
knowledgeOpenIds: new Set(readAdminLayoutState().knowledgeOpenIds || []),
templates: [],
selected: null,
clipboard: null,
drag: null,
dragTarget: null,
dirty: false,
filePickerActive: false,
mediaUploadActive: false,
filePickerResetTimer: null,
deleteConfirmResolve: null,
mediaActionResolve: null,
knowledgeButtonResolve: 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 PAGE_SEO_DEFAULTS = {
title: "NODE.DC - платформа для процессов, данных и ИИ-агентов",
description:
"NODE.DC объединяет Hub, Engine, Ops, прикладные модули и сквозного ассистента для задач, workflow, данных, ролей, интеграций и инженерных моделей.",
canonicalUrl: "https://nodedc.dctouch.ru/",
robots: "index,follow,max-image-preview:large",
siteName: "NODE.DC",
locale: "ru_RU",
ogImage: "https://nodedc.dctouch.ru/assets/site/images/69b6acdde3123ef078e2b7f3_og-img.jpg",
imageAlt: "NODE.DC - платформа для процессов, данных и ИИ-агентов",
organizationName: "DCTOUCH",
organizationUrl: "https://dctouch.ru/",
applicationCategory: "BusinessApplication",
};
const BLOCK_SEO_DEFAULTS = {
targetCluster: "",
searchIntent: "",
primaryQuery: "",
secondaryQueries: "",
title: "",
description: "",
coverageNotes: "",
indexHint: "section",
};
const PAGE_SEO_FIELDS = [
{ path: "seo.title", label: "Title страницы", kind: "text", group: "Сниппет", min: 35, max: 70 },
{ path: "seo.description", label: "Description страницы", kind: "textarea", group: "Сниппет", min: 110, max: 170 },
{ path: "seo.canonicalUrl", label: "Canonical URL", kind: "url", group: "Индексация" },
{
path: "seo.robots",
label: "Robots meta",
kind: "select",
group: "Индексация",
options: ["index,follow,max-image-preview:large", "noindex,nofollow", "index,follow"],
},
{ path: "seo.siteName", label: "Site name", kind: "text", group: "Open Graph" },
{ path: "seo.locale", label: "Locale", kind: "text", group: "Open Graph" },
{ path: "seo.ogImage", label: "OG image", kind: "url", group: "Open Graph" },
{ path: "seo.imageAlt", label: "OG image alt", kind: "textarea", group: "Open Graph", min: 20, max: 120 },
{ path: "seo.organizationName", label: "Organization", kind: "text", group: "Schema.org" },
{ path: "seo.organizationUrl", label: "Organization URL", kind: "url", group: "Schema.org" },
{ path: "seo.applicationCategory", label: "Application category", kind: "text", group: "Schema.org" },
];
const BLOCK_SEO_FIELDS = [
{ path: "seo.targetCluster", label: "Кластер", kind: "text", group: "Семантика" },
{ path: "seo.searchIntent", label: "Интент", kind: "textarea", group: "Семантика", min: 20, max: 220 },
{ path: "seo.primaryQuery", label: "Основной запрос", kind: "text", group: "Семантика" },
{ path: "seo.secondaryQueries", label: "Смежные запросы", kind: "textarea", group: "Семантика" },
{ path: "seo.title", label: "SEO-заголовок блока", kind: "text", group: "Сниппет блока", min: 35, max: 80 },
{ path: "seo.description", label: "SEO-описание блока", kind: "textarea", group: "Сниппет блока", min: 90, max: 180 },
{
path: "seo.indexHint",
label: "Роль в индексации",
kind: "select",
group: "Индексация",
options: ["section", "supporting", "conversion", "noindex"],
},
{ path: "seo.coverageNotes", label: "Заметки по охвату", kind: "textarea", group: "Аналитика", min: 20, max: 500 },
];
const el = {
modeContent: document.querySelector("#admin-mode-content"),
modeSeo: document.querySelector("#admin-mode-seo"),
modeKnowledge: document.querySelector("#admin-mode-knowledge"),
modeNav: document.querySelector(".nodedc-expanded-nav-group"),
sidebarTitle: document.querySelector("#sidebar-title"),
workspaceName: document.querySelector("#workspace-name"),
workspaceRole: document.querySelector("#workspace-role"),
editorHeading: document.querySelector("#editor-heading"),
panelKicker: document.querySelector("#content-panel-kicker"),
panelTitle: document.querySelector("#content-panel-title"),
panelNote: document.querySelector("#content-panel-note"),
regions: document.querySelector("#regions"),
addProject: document.querySelector("#add-project"),
projectSettings: document.querySelector("#project-settings"),
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"),
knowledgeForm: document.querySelector("#knowledge-form"),
projectSettingsForm: document.querySelector("#project-settings-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"),
targetOptions: document.querySelector("#block-target-options"),
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"),
knowledgeButtonModal: document.querySelector("#knowledge-button-modal"),
knowledgeButtonClose: document.querySelector("#knowledge-button-close"),
knowledgeButtonName: document.querySelector("#knowledge-button-name"),
knowledgeButtonLink: document.querySelector("#knowledge-button-link"),
knowledgeButtonTarget: document.querySelector("#knowledge-button-target"),
knowledgeButtonTargetOptions: document.querySelector("#knowledge-button-target-options"),
knowledgeButtonError: document.querySelector("#knowledge-button-error"),
knowledgeButtonCancel: document.querySelector("#knowledge-button-cancel"),
knowledgeButtonConfirm: document.querySelector("#knowledge-button-confirm"),
};
const STATIC_FALLBACK = {
id: "static-elements",
type: "staticElements",
adminLabel: "Статичные элементы",
enabled: true,
content: {
navigation: {
logoSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
{ enabled: true, label: "О продукте", href: "", target: "index.html#platform-composition", linkMode: "target", linkTarget: "" },
{ enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" },
{
enabled: true,
label: "Документация",
href: "https://nodedc.dctouch.ru/",
target: "",
linkMode: "href",
linkTarget: "_blank",
},
{ enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-intro", 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/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
iconAlt: "Иконка NODE.DC",
className: "dock-icon nodedc-app",
iconClass: "logo",
dividerBefore: false,
},
{
enabled: true,
title: "Корзина",
href: "javascript:void(0)",
linkTarget: "",
targetId: "",
iconSrc: "./assets/site/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/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png",
logoPngAlt: "Логотип NODE.DC",
appIconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
appIconAlt: "Иконка NODE.DC",
appWordmarkSrc: "./assets/site/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 nodedc-app",
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-сервер и контекст NODE.DC.",
"Окно 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/site/images", "NODE.DC"],
["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.knowledgeForm.classList.add("hidden");
el.projectSettingsForm.classList.add("hidden");
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 escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getByPath(source, path) {
return path.split(".").reduce((value, key) => value?.[key], source);
}
function setByPath(source, path, nextValue) {
const keys = path.split(".");
const lastKey = keys.pop();
let target = source;
keys.forEach((key, index) => {
const nextKey = keys[index + 1] ?? lastKey;
if (target[key] == null || typeof target[key] !== "object") {
target[key] = /^\d+$/.test(nextKey) ? [] : {};
}
target = target[key];
});
target[lastKey] = nextValue;
}
function isProjectSettingsSelected() {
return state.selected?.kind === "project-settings";
}
function projectName() {
return state.projectConfig?.name || "NODE.DC";
}
function projectRole() {
return state.projectConfig?.role || "Администратор сайта";
}
function siteStatus() {
return state.projectConfig?.site || { ready: false, missingFiles: [] };
}
function siteReady() {
return Boolean(siteStatus().ready);
}
function applyProjectConfigSurface() {
if (el.workspaceName) el.workspaceName.textContent = projectName();
if (el.workspaceRole && !isProjectSettingsSelected()) {
el.workspaceRole.textContent =
state.mode === "knowledge" ? "Редактор материалов" : state.mode === "seo" ? "SEO-режим сайта" : projectRole();
}
}
async function loadProjectConfig() {
const response = await fetch("/api/project/config");
const payload = await response.json().catch(() => null);
if (!response.ok || !payload) {
throw new Error(payload?.error || "Не удалось загрузить настройки проекта");
}
state.projectConfig = payload;
state.projectConfigDirty = false;
applyProjectConfigSurface();
return payload;
}
function projectConfigValue(path) {
return getByPath(state.projectConfig || {}, path);
}
function setProjectConfigValue(path, value) {
if (!state.projectConfig) state.projectConfig = {};
setByPath(state.projectConfig, path, value);
state.projectConfigDirty = true;
}
function normalizeProjectInputValue(input) {
if (input.type === "checkbox") return input.checked;
if (input.type === "number") return Number(input.value || 0);
return input.value.trim();
}
function syncProjectConfigFromForm() {
if (!el.projectSettingsForm || !state.projectConfig) return;
for (const input of el.projectSettingsForm.querySelectorAll("[data-project-path]")) {
setByPath(state.projectConfig, input.dataset.projectPath, normalizeProjectInputValue(input));
}
}
async function saveProjectSettings() {
syncProjectConfigFromForm();
const passwordEnv = String(state.projectConfig?.deploy?.passwordEnv || "").trim();
if (passwordEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(passwordEnv)) {
setByPath(state.projectConfig, "deploy.passwordEnv", "MCHOST_PASSWORD");
renderProjectSettings();
setStatus("FTP-пароль не сохраняем в модель. Поле сброшено к имени переменной MCHOST_PASSWORD.");
return;
}
const response = await fetch("/api/project/config", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(state.projectConfig),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось сохранить настройки проекта");
}
state.projectConfig = payload.project;
state.projectConfigDirty = false;
setDirty(false);
applyProjectConfigSurface();
renderProjectSettings();
setStatus("Настройки проекта сохранены.");
}
async function checkProjectConnection() {
const [pageResponse, knowledgeResponse, sizeResponse] = await Promise.all([
fetch("/api/page/home"),
fetch("/api/knowledge"),
fetch("/api/media/site-size"),
]);
if (!pageResponse.ok) throw new Error(`home.json: ${await pageResponse.text()}`);
if (!knowledgeResponse.ok) throw new Error(`knowledge.json: ${await knowledgeResponse.text()}`);
const size = sizeResponse.ok ? await sizeResponse.json() : null;
setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`);
}
function ensurePageSeo() {
if (!state.page) return;
state.page.seo = {
...PAGE_SEO_DEFAULTS,
...(state.page.seo || {}),
};
}
function ensureBlockSeo(block) {
if (!block || isStaticSelection()) return;
block.seo = {
...BLOCK_SEO_DEFAULTS,
...(block.seo || {}),
};
}
function ensureSeoModel() {
ensurePageSeo();
for (const region of state.page?.regions || []) {
for (const block of region.blocks || []) {
block.seo = {
...BLOCK_SEO_DEFAULTS,
...(block.seo || {}),
};
}
}
}
function setAdminMode(nextMode) {
if (!siteReady() && !isProjectSettingsSelected()) {
state.mode = "content";
updateAdminLayoutState({ mode: state.mode });
renderSiteUnavailable();
return;
}
const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content";
state.mode = mode;
if (isProjectSettingsSelected()) {
state.selected = mode === "knowledge" ? null : { kind: "static" };
}
updateAdminLayoutState({ mode });
if (mode === "knowledge" && !state.knowledge) {
loadKnowledge().catch((error) => setStatus(error.message));
updateAdminModeSurface();
return;
}
if (state.page) {
renderAll();
} else {
updateAdminModeSurface();
}
}
function updateAdminModeSurface() {
const isSeo = state.mode === "seo";
const isKnowledge = state.mode === "knowledge";
const hasSite = siteReady();
document.body.dataset.adminMode = state.mode;
el.modeNav?.classList.toggle("hidden", !hasSite);
if (el.modeContent) el.modeContent.dataset.active = String(!isSeo && !isKnowledge);
if (el.modeSeo) el.modeSeo.dataset.active = String(isSeo);
if (el.modeKnowledge) el.modeKnowledge.dataset.active = String(isKnowledge);
if (el.sidebarTitle) el.sidebarTitle.textContent = isKnowledge ? "База знаний" : isSeo ? "SEO" : "Администрирование";
if (el.workspaceRole) {
el.workspaceRole.textContent = isKnowledge ? "Редактор материалов" : isSeo ? "SEO-режим сайта" : projectRole();
}
if (el.editorHeading) el.editorHeading.textContent = isKnowledge ? "База знаний" : isSeo ? "SEO сайта" : "Настройки сайта";
if (el.panelKicker) el.panelKicker.textContent = isKnowledge ? "Knowledge" : isSeo ? "SEO" : "Контент";
if (el.panelTitle) el.panelTitle.textContent = isKnowledge ? "Дерево и статья" : isSeo ? "Семантика и сниппеты" : "Редактируемые поля";
if (el.panelNote) {
el.panelNote.textContent = isKnowledge
? "Материалы генерируются в статические SEO-страницы раздела /knowledge/."
: isSeo
? "Поля готовят страницу и секции к семантической настройке и дальнейшей автоматизации."
: "Поля сгруппированы по смысловым подблокам верстки.";
}
if (el.addBlock) {
el.addBlock.title = isKnowledge ? "Добавить раздел в корень базы знаний" : "Добавить блок";
el.addBlock.setAttribute("aria-label", el.addBlock.title);
el.addBlock.disabled = !hasSite;
}
if (el.addProject) {
el.addProject.title = "Добавить сайт";
el.addProject.setAttribute("aria-label", "Добавить сайт");
}
if (el.save) {
el.save.textContent = "Сохранить";
el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json";
el.save.disabled = !hasSite && !isProjectSettingsSelected();
}
if (el.render) {
el.render.textContent = "Опубликовать";
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
el.render.disabled = !hasSite && !isProjectSettingsSelected();
}
if (el.reload) {
el.reload.disabled = !hasSite && !isProjectSettingsSelected();
}
}
function editorJsonPayload(block) {
if (state.mode === "seo" && isStaticSelection()) {
return state.page?.seo || {};
}
return block || {};
}
function groupedSeoFields(fields) {
const groups = new Map();
for (const seoField of fields) {
const groupName = seoField.group || "SEO";
if (!groups.has(groupName)) groups.set(groupName, []);
groups.get(groupName).push(seoField);
}
return groups;
}
function compactText(value) {
if (value == null) return "";
if (Array.isArray(value)) return value.map(compactText).join(" ");
if (typeof value === "object") return Object.values(value).map(compactText).join(" ");
return String(value)
.replace(/<[^>]*>/g, " ")
.replace(/https?:\/\/\S+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function seoLengthStatus(value, min, max) {
const length = String(value || "").trim().length;
if (!min && !max) return { status: "info", text: length ? `${length} симв.` : "пусто" };
if (!length) return { status: "bad", text: `пусто · цель ${min || 0}-${max || "∞"}` };
if ((min && length < min) || (max && length > max)) {
return { status: "warn", text: `${length} симв. · цель ${min || 0}-${max || "∞"}` };
}
return { status: "good", text: `${length} симв.` };
}
function createSeoMetric(label, value, status = "info") {
const item = document.createElement("div");
const caption = document.createElement("span");
const number = document.createElement("strong");
item.className = "seo-metric";
item.dataset.status = status;
caption.textContent = label;
number.textContent = value;
item.append(caption, number);
return item;
}
function blockTextStats(block) {
const text = compactText(block?.content || "");
const words = text ? text.split(/\s+/).filter(Boolean).length : 0;
return { text, words, chars: text.length };
}
function renderSeoDiagnostics({ targetModel, fields, isStatic }) {
const diagnostics = document.createElement("section");
const head = document.createElement("div");
const title = document.createElement("h3");
const desc = document.createElement("p");
const metrics = document.createElement("div");
diagnostics.className = "seo-diagnostics";
head.className = "group-head";
title.textContent = isStatic ? "Диагностика страницы" : "Диагностика блока";
desc.className = "group-desc";
desc.textContent = isStatic
? "Базовые поля, которые попадают в head, Open Graph, sitemap и structured data."
: "Черновой слой для семантики блока. Позже его сможет заполнять внешний SEO-модуль.";
head.append(title, desc);
metrics.className = "seo-metric-grid";
if (isStatic) {
const titleStatus = seoLengthStatus(getByPath(targetModel, "seo.title"), 35, 70);
const descriptionStatus = seoLengthStatus(getByPath(targetModel, "seo.description"), 110, 170);
const canonical = String(getByPath(targetModel, "seo.canonicalUrl") || "");
const ogImage = String(getByPath(targetModel, "seo.ogImage") || "");
metrics.append(
createSeoMetric("Title", titleStatus.text, titleStatus.status),
createSeoMetric("Description", descriptionStatus.text, descriptionStatus.status),
createSeoMetric("Canonical", canonical.startsWith("https://") ? "абсолютный" : "проверить", canonical.startsWith("https://") ? "good" : "warn"),
createSeoMetric("OG image", ogImage ? "задан" : "пусто", ogImage ? "good" : "bad"),
);
} else {
const stats = blockTextStats(targetModel);
const primaryQuery = String(getByPath(targetModel, "seo.primaryQuery") || "").trim();
const secondary = String(getByPath(targetModel, "seo.secondaryQueries") || "")
.split(/\n|,/)
.map((item) => item.trim())
.filter(Boolean);
const titleStatus = seoLengthStatus(getByPath(targetModel, "seo.title"), 35, 80);
metrics.append(
createSeoMetric("Текст блока", `${stats.words} слов`, stats.words >= 40 ? "good" : "warn"),
createSeoMetric("Основной запрос", primaryQuery ? "задан" : "пусто", primaryQuery ? "good" : "bad"),
createSeoMetric("Смежные запросы", `${secondary.length}`, secondary.length ? "good" : "warn"),
createSeoMetric("SEO-заголовок", titleStatus.text, titleStatus.status),
);
}
diagnostics.append(head, metrics);
return diagnostics;
}
function updateSeoFieldMeter(meter, input, config) {
if (!meter) return;
const lengthStatus = seoLengthStatus(fieldInputValue(input), config.min, config.max);
meter.dataset.status = lengthStatus.status;
meter.textContent = lengthStatus.text;
}
function renderSeoInput({ targetModel, seoField, 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 isLong = seoField.kind === "textarea";
const input = seoField.kind === "select" ? document.createElement("select") : isLong ? document.createElement("textarea") : document.createElement("input");
const value = getByPath(targetModel, seoField.path) ?? "";
const meter = seoField.min || seoField.max ? document.createElement("span") : null;
label.className = `field-control seo-field${isLong ? " wide" : ""}`;
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = seoField.label || seoField.path;
kind.className = "field-kind";
kind.textContent = seoField.kind || "text";
path.className = "field-path";
path.textContent = seoField.path;
labelRow.append(labelText, kind);
if (seoField.kind === "select") {
const values = new Set([...(seoField.options || []), value].filter(Boolean));
for (const optionValue of values) {
const option = document.createElement("option");
option.value = optionValue;
option.textContent = optionValue;
input.append(option);
}
} else {
if (!isLong) {
input.autocomplete = "off";
input.type = seoField.kind === "url" ? "url" : "text";
}
}
input.dataset.seoPath = seoField.path;
input.value = value;
input.placeholder = seoField.placeholder || "";
updateSeoFieldMeter(meter, input, seoField);
input.addEventListener(seoField.kind === "select" ? "change" : "input", () => {
setByPath(targetModel, seoField.path, fieldInputValue(input));
updateSeoFieldMeter(meter, input, seoField);
el.json.value = JSON.stringify(editorJsonPayload(activeBlock()), null, 2);
markDirty("SEO-поля обновлены локально. Нажмите «Сохранить модель» или «Обновить index.html».");
});
label.append(labelRow, input);
if (meter) {
meter.className = "seo-field-meter";
label.append(meter);
}
label.append(path);
grid.append(label);
}
function renderSeoFields(block) {
const isStatic = isStaticSelection();
const targetModel = isStatic ? state.page : block;
const fields = isStatic ? PAGE_SEO_FIELDS : BLOCK_SEO_FIELDS;
if (isStatic) ensurePageSeo();
else ensureBlockSeo(block);
el.contentFields.innerHTML = "";
el.contentFields.append(renderSeoDiagnostics({ targetModel, fields, isStatic }));
for (const [groupName, groupFields] of groupedSeoFields(fields)) {
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 seo-group";
head.className = "group-head";
title.textContent = groupName;
desc.className = "group-desc";
desc.textContent = isStatic ? "Поля уровня страницы и поискового сниппета." : "Поля уровня секции для будущей семантической карты.";
titleWrap.append(title, desc);
head.append(titleWrap);
grid.className = "group-grid";
for (const seoField of groupFields) {
renderSeoInput({ targetModel, seoField, grid });
}
group.append(head, grid);
el.contentFields.append(group);
}
}
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 normalizeLandingTargetCandidate(value) {
const raw = String(value || "").trim();
if (!raw || /^javascript:/i.test(raw) || isExternalHref(raw)) return "";
let target = raw;
if (target.includes("#")) {
target = target.slice(target.indexOf("#") + 1);
}
target = target
.replace(/^\/+/, "")
.replace(/^index\.html#?/i, "")
.replace(/^#+/, "")
.trim();
if (!target || target === "/" || target === "index.html" || target.startsWith("knowledge/") || isWindowTarget(target)) {
return "";
}
return target;
}
function landingBlockTargets() {
const targets = [];
for (const region of state.page?.regions || []) {
for (const block of region.blocks || []) {
if (block.enabled === false) continue;
const target = normalizeLandingTargetCandidate(block.anchor);
if (!target) continue;
targets.push({
block,
id: block.id,
label: block.adminLabel || block.id,
target,
});
}
}
return targets.sort((left, right) => left.target.localeCompare(right.target, "ru"));
}
function collectLandingButtonTargets() {
return landingBlockTargets().map((item) => item.target);
}
function findLandingTargetByInput(value) {
const input = String(value || "").trim();
const normalized = normalizeLandingTargetCandidate(input);
const lowerInput = input.toLowerCase();
const lowerTarget = normalized.toLowerCase();
return (
landingBlockTargets().find((item) => item.target.toLowerCase() === lowerTarget) ||
landingBlockTargets().find((item) => item.id.toLowerCase() === lowerInput) ||
landingBlockTargets().find((item) => item.label.toLowerCase() === lowerInput) ||
null
);
}
function uniqueLandingTarget(base, exceptBlock = null) {
const usedTargets = new Set(
landingBlockTargets()
.filter((item) => item.block !== exceptBlock)
.map((item) => item.target),
);
const root = safeBucketSegment(normalizeLandingTargetCandidate(base) || base || "section");
let candidate = root;
let index = 2;
while (usedTargets.has(candidate)) {
candidate = `${root}-${index}`;
index += 1;
}
return candidate;
}
function landingTargetDuplicates() {
const seen = new Map();
const duplicates = [];
for (const item of landingBlockTargets()) {
if (seen.has(item.target)) {
duplicates.push({ target: item.target, first: seen.get(item.target), second: item });
continue;
}
seen.set(item.target, item);
}
return duplicates;
}
function assertUniqueLandingTargets() {
const duplicates = landingTargetDuplicates();
if (!duplicates.length) return;
const details = duplicates
.map(({ target, first, second }) => `${target}: ${first.label} / ${second.label}`)
.join("; ");
throw new Error(`Target должен быть уникальным для каждого блока. Дубли: ${details}`);
}
function renderLandingTargetOptions() {
if (!el.targetOptions) return;
el.targetOptions.innerHTML = "";
for (const { target, label } of landingBlockTargets()) {
const option = document.createElement("option");
option.value = target;
option.label = label;
el.targetOptions.append(option);
}
}
function knowledgeTargetPromptText() {
const targets = landingBlockTargets();
const hint = targets.length ? `\n\nДоступные блоки:\n${targets.map((item) => `${item.target}${item.label}`).join("\n")}` : "";
return `Target или ID блока лендинга. Если пусто, откроется первый экран.${hint}`;
}
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 nodedc-app",
iconClass: "logo",
dividerBefore: false,
},
{
enabled: true,
title: "Корзина",
href: "javascript:void(0)",
linkTarget: "",
targetId: "",
iconSrc: "./assets/site/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 nodedc-app",
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;
if (block.anchor) {
block.anchor = uniqueLandingTarget(block.anchor || block.adminLabel || block.id, block);
}
block.scopeIds = true;
block.seo = {
...BLOCK_SEO_DEFAULTS,
...(block.seo || {}),
};
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.knowledgeForm.classList.add("hidden");
el.projectSettingsForm.classList.add("hidden");
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 createProjectField({ path, label, type = "text", placeholder = "", options = [], hint = "" }) {
const field = document.createElement("label");
const title = document.createElement("span");
const badge = document.createElement("span");
const control = options.length ? document.createElement("select") : document.createElement("input");
const value = projectConfigValue(path);
const isSecret = type === "password";
field.className = type === "checkbox" ? "toggle-row" : "field-control";
title.textContent = label;
if (type === "checkbox") {
control.type = "checkbox";
control.checked = Boolean(value);
field.append(control, title);
} else {
if (options.length) {
for (const optionValue of options) {
const option = document.createElement("option");
option.value = optionValue;
option.textContent = optionValue;
control.append(option);
}
control.value = value || options[0] || "";
} else {
control.type = type;
control.value = value ?? "";
control.placeholder = placeholder;
}
badge.className = "field-kind";
badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : isSecret ? "SECRET" : "TEXT";
field.append(title, badge);
if (isSecret) {
const secretWrap = document.createElement("span");
const reveal = document.createElement("button");
secretWrap.className = "secret-input-wrap";
reveal.className = "secret-toggle-button";
reveal.type = "button";
reveal.innerHTML =
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.75 12s3.25-5.5 9.25-5.5 9.25 5.5 9.25 5.5-3.25 5.5-9.25 5.5S2.75 12 2.75 12Z"></path><path d="M12 9.25a2.75 2.75 0 1 1 0 5.5 2.75 2.75 0 0 1 0-5.5Z"></path></svg>';
reveal.setAttribute("aria-label", "Показать значение");
reveal.title = "Показать значение";
reveal.addEventListener("click", () => {
const visible = control.type === "text";
control.type = visible ? "password" : "text";
reveal.setAttribute("aria-label", visible ? "Показать значение" : "Скрыть значение");
reveal.title = visible ? "Показать значение" : "Скрыть значение";
});
secretWrap.append(control, reveal);
field.append(secretWrap);
} else {
field.append(control);
}
}
control.dataset.projectPath = path;
control.addEventListener("input", () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
});
control.addEventListener("change", () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
});
if (hint && type !== "checkbox") {
const note = document.createElement("small");
note.className = "field-hint";
note.textContent = hint;
field.append(note);
}
return field;
}
function createProjectSettingsCard({ kicker, title, note = "", fields = [], runtime = false, actions = null }) {
const card = document.createElement("section");
const head = document.createElement("div");
const copy = document.createElement("div");
const kickerNode = document.createElement("div");
const titleNode = document.createElement("h3");
const body = document.createElement("div");
card.className = "settings-card";
head.className = "card-head";
copy.append(kickerNode, titleNode);
kickerNode.className = "section-kicker";
kickerNode.textContent = kicker;
titleNode.textContent = title;
head.append(copy);
if (note) {
const noteNode = document.createElement("p");
noteNode.className = "project-settings-hint";
noteNode.textContent = note;
head.append(noteNode);
}
body.className = runtime ? "project-settings-runtime" : "field-grid";
fields.forEach((field) => body.append(field));
card.append(head, body);
if (actions) {
const foot = document.createElement("div");
foot.className = "project-settings-actions";
foot.append(...actions);
card.append(foot);
}
return card;
}
function renderProjectRuntimeLine(label, value) {
const row = document.createElement("div");
const code = document.createElement("code");
row.append(`${label}: `, code);
code.textContent = value || "не задано";
return row;
}
function renderEmptyState({ icon = "ND", title, body, stateName = "" }) {
el.empty.dataset.state = stateName;
el.empty.innerHTML = `
<div class="empty-icon">${escapeHtml(icon)}</div>
<div>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(body)}</p>
</div>
`;
}
function renderSiteUnavailable() {
updateAdminModeSurface();
applyProjectConfigSurface();
el.regions.innerHTML = "";
el.form.classList.add("hidden");
el.knowledgeForm.classList.add("hidden");
el.projectSettingsForm.classList.add("hidden");
el.empty.classList.remove("hidden");
const missing = siteStatus().missingFiles || [];
const missingText = missing.length ? ` Сейчас отсутствуют: ${missing.join(", ")}.` : "";
const root = siteStatus().root || state.projectConfig?.runtime?.siteRoot || "";
const rootText = root ? ` Локальная папка: ${root}.` : "";
renderEmptyState({
icon: "DC",
title: "Сайт недоступен",
body: `Проверьте настройки подключения к хостингу или загрузите архив сайта в локальный workspace CMS.${missingText}${rootText}`,
stateName: "site-unavailable",
});
if (el.editorHeading) el.editorHeading.textContent = "DC CMS";
el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`;
el.selectedTitle.textContent = "Подключите сайт";
setDirty(false);
}
function renderProjectSettings() {
updateAdminModeSurface();
el.empty.classList.add("hidden");
el.form.classList.add("hidden");
el.knowledgeForm.classList.add("hidden");
el.projectSettingsForm.classList.remove("hidden");
el.projectSettingsForm.innerHTML = "";
if (!state.projectConfig) {
el.projectSettingsForm.innerHTML = '<div class="empty-note">Загружаем настройки проекта...</div>';
loadProjectConfig().then(renderProjectSettings).catch((error) => setStatus(error.message));
return;
}
if (el.workspaceRole) el.workspaceRole.textContent = "Настройки подключения";
if (el.editorHeading) el.editorHeading.textContent = "Настройки CMS";
el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`;
el.selectedTitle.textContent = "Проект и деплой";
if (el.save) {
el.save.textContent = "Сохранить настройки";
el.save.title = "Записать настройки проекта в projects/nodedc.json";
}
if (el.render) {
el.render.textContent = "Проверить проект";
el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта";
}
const projectCard = createProjectSettingsCard({
kicker: "Проект",
title: "Подключенный сайт",
note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.",
fields: [
createProjectField({ path: "name", label: "Название проекта" }),
createProjectField({ path: "role", label: "Роль в сайдбаре" }),
createProjectField({ path: "siteRoot", label: "Локальный путь сайта", placeholder: "../NODEDC_SITE" }),
createProjectField({ path: "previewUrl", label: "Preview URL", placeholder: "http://127.0.0.1:8210/" }),
createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }),
],
});
const deployCard = createProjectSettingsCard({
kicker: "Хостинг",
title: "Публикация на McHost",
note: "Для McHost обычно используются FTP host вида aXXXX.ftp.mchost.ru, порт 21 для FTP или 22 для SFTP и папка сайта httpdocs. Пароль не пишем в JSON: passwordEnv хранит имя переменной окружения сервиса.",
fields: [
createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }),
createProjectField({ path: "deploy.deployOnRender", label: "Деплоить после рендера", type: "checkbox" }),
createProjectField({ path: "deploy.method", label: "Метод", options: ["sftp", "ftp"] }),
createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }),
createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }),
createProjectField({ path: "deploy.username", label: "Логин" }),
createProjectField({
path: "deploy.passwordEnv",
label: "Имя переменной пароля",
type: "password",
placeholder: "MCHOST_PASSWORD",
hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.",
}),
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
],
});
const runtime = state.projectConfig.runtime || {};
const runtimeCard = createProjectSettingsCard({
kicker: "Runtime",
title: "Текущая связка",
runtime: true,
fields: [
renderProjectRuntimeLine("CMS root", runtime.cmsRoot),
renderProjectRuntimeLine("Site root", runtime.siteRoot),
renderProjectRuntimeLine("Config", runtime.configPath),
renderProjectRuntimeLine("Важно", runtime.siteRootChangesRequireRestart ? "изменение siteRoot применится после перезапуска CMS" : ""),
],
});
el.projectSettingsForm.append(projectCard, deployCard, runtimeCard);
}
function renderEditor() {
updateAdminModeSurface();
el.knowledgeForm.classList.add("hidden");
el.projectSettingsForm.classList.add("hidden");
const region = activeRegion();
const block = activeBlock();
const isStatic = isStaticSelection();
const isSeo = state.mode === "seo";
if (!block) {
el.empty.classList.remove("hidden");
el.form.classList.add("hidden");
renderEmptyState({
icon: "ND",
title: "Выберите блок слева",
body: "Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.",
});
el.selectedRegion.textContent = "NODE.DC / Выберите блок";
el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой";
renderLandingTargetOptions();
return;
}
el.empty.classList.add("hidden");
el.form.classList.remove("hidden");
el.form.dataset.static = String(isStatic);
el.form.dataset.mode = state.mode;
el.selectedRegion.textContent = isSeo
? `NODE.DC / SEO / ${isStatic ? "Страница" : region.label || region.id}`
: `NODE.DC / ${region.label || region.id}`;
el.selectedTitle.textContent = isSeo ? `SEO: ${isStatic ? "страница" : block.adminLabel || block.id}` : 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.anchor.placeholder = "hub-video / engine-video / applied-modules";
el.anchor.title = "Публичный Target конкретного блока: используется как якорь лендинга и как ссылка из Knowledge.";
renderLandingTargetOptions();
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 || isSeo);
try {
if (isSeo) {
renderSeoFields(block);
} else {
renderContentFields(block);
}
} catch (error) {
renderEditorError(error);
}
el.json.value = JSON.stringify(editorJsonPayload(block), null, 2);
}
function renderAll() {
try {
if (!siteReady() && !isProjectSettingsSelected()) {
renderSiteUnavailable();
return;
}
if (isProjectSettingsSelected()) {
if (state.mode === "knowledge" && state.knowledge) {
ensureKnowledgeSelection();
renderKnowledgeRegions();
} else {
renderRegions();
}
renderProjectSettings();
return;
}
if (state.mode === "knowledge") {
renderKnowledgeAll();
return;
}
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 = normalizeLandingTargetCandidate(el.anchor.value) || null;
el.anchor.value = block.anchor || "";
block.enabled = el.enabled.checked;
renderLandingTargetOptions();
const duplicate = landingTargetDuplicates().find((item) => item.second.block === block || item.first.block === block);
if (duplicate) {
setStatus(`Target «${duplicate.target}» уже используется в другом блоке. Переименуйте один из target-ов перед сохранением.`);
}
}
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 currentValue = context.hiddenInput?.value || "";
const currentValuePath = pathFromUrl(currentValue);
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 = currentValue;
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: currentValue });
} 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;
}
if (typeof context.onSelect === "function") {
context.onSelect(file.url, file);
} else {
updateMediaFieldValue({
...context,
value: file.url,
});
context.setSource("file");
}
rememberMediaDirectory(file.directory || parentMediaPath(pathFromUrl(file.url)));
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 (state.mode === "seo" && isStaticSelection()) {
state.page.seo = parsed;
} else 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;
}
}
function knowledgeTree() {
if (!state.knowledge) return [];
if (!Array.isArray(state.knowledge.tree)) state.knowledge.tree = [];
return state.knowledge.tree;
}
function knowledgeWalk(nodes = knowledgeTree(), parent = null, callback = () => {}) {
nodes.forEach((node, index) => {
callback(node, parent, nodes, index);
knowledgeWalk(Array.isArray(node.children) ? node.children : [], node, callback);
});
}
function allKnowledgeIds() {
const ids = new Set();
knowledgeWalk(knowledgeTree(), null, (node) => {
if (node.id) ids.add(node.id);
});
return ids;
}
function findKnowledgeNode(id) {
let found = null;
let foundParent = null;
let foundSiblings = null;
let foundIndex = -1;
knowledgeWalk(knowledgeTree(), null, (node, parent, siblings, index) => {
if (found || node.id !== id) return;
found = node;
foundParent = parent;
foundSiblings = siblings;
foundIndex = index;
});
return found ? { node: found, parent: foundParent, siblings: foundSiblings, index: foundIndex } : null;
}
function isKnowledgeRootId(id) {
return id === KNOWLEDGE_ROOT_ID;
}
function isKnowledgeRootSelected() {
return isKnowledgeRootId(state.knowledgeSelectedId);
}
function knowledgeRootTitle() {
return state.knowledge?.title || state.knowledge?.settings?.navTitle || "Knowledge";
}
function selectedKnowledgeNode() {
if (isKnowledgeRootSelected()) return state.knowledge;
return findKnowledgeNode(state.knowledgeSelectedId)?.node || null;
}
function knowledgeSlug(value, fallback = "material") {
const map = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "c",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya",
};
const safe = String(value || "")
.toLowerCase()
.replace(/[а-яё]/g, (char) => map[char] || "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return safe || fallback;
}
function uniqueKnowledgeValue(values, base) {
const cleanBase = base || "material";
if (!values.has(cleanBase)) return cleanBase;
let index = 2;
while (values.has(`${cleanBase}-${index}`)) index += 1;
return `${cleanBase}-${index}`;
}
function createKnowledgeNode(title = "Новый материал", siblings = []) {
const ids = allKnowledgeIds();
const siblingSlugs = new Set(siblings.map((node) => node.slug).filter(Boolean));
const slug = uniqueKnowledgeValue(siblingSlugs, knowledgeSlug(title));
return {
id: uniqueKnowledgeValue(ids, slug),
title,
slug,
enabled: true,
useContent: true,
seo: {
title,
description: "",
},
bodyHtml: "<p>Новый материал базы знаний.</p>",
children: [],
};
}
function ensureKnowledgeSelection() {
if (!state.knowledge) return;
if (isKnowledgeRootSelected()) return;
const existing = state.knowledgeSelectedId ? findKnowledgeNode(state.knowledgeSelectedId) : null;
if (existing) return;
state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID;
}
function selectKnowledgeNode(id) {
state.knowledgeSelectedId = id;
updateAdminLayoutState({ knowledgeSelectedId: id });
renderKnowledgeAll();
}
function persistKnowledgeOpenState() {
updateAdminLayoutState({ knowledgeOpenIds: [...state.knowledgeOpenIds] });
}
function setKnowledgeNodeOpen(id, open) {
if (!id) return;
if (open) state.knowledgeOpenIds.add(id);
else state.knowledgeOpenIds.delete(id);
persistKnowledgeOpenState();
}
function addKnowledgeNode(parentId = null) {
const parent = parentId ? findKnowledgeNode(parentId)?.node : null;
const siblings = parent ? (parent.children ||= []) : knowledgeTree();
const node = createKnowledgeNode(parent ? "Новый подраздел" : "Новый раздел", siblings);
siblings.push(node);
if (parent) setKnowledgeNodeOpen(parent.id, true);
state.knowledgeSelectedId = node.id;
renderKnowledgeAll();
markDirty("Материал добавлен в дерево базы знаний.");
}
function moveKnowledgeNode(id, delta) {
const match = findKnowledgeNode(id);
if (!match) return;
const nextIndex = match.index + delta;
if (nextIndex < 0 || nextIndex >= match.siblings.length) return;
const [node] = match.siblings.splice(match.index, 1);
match.siblings.splice(nextIndex, 0, node);
renderKnowledgeAll();
markDirty("Порядок дерева базы знаний изменён.");
}
async function deleteKnowledgeNode(id) {
const match = findKnowledgeNode(id);
if (!match) return;
const confirmed = await requestDeleteConfirmation({
title: "Удалить материал?",
body: `«${match.node.title || match.node.id}» и все вложенные элементы будут удалены из дерева.`,
});
if (!confirmed) return;
match.siblings.splice(match.index, 1);
if (state.knowledgeSelectedId === id) {
state.knowledgeSelectedId = match.siblings[Math.min(match.index, match.siblings.length - 1)]?.id || match.parent?.id || KNOWLEDGE_ROOT_ID;
}
renderKnowledgeAll();
markDirty("Материал удалён из дерева базы знаний.");
}
function renderKnowledgeTreeItem(node, depth = 0) {
const item = document.createElement("div");
const row = document.createElement("div");
const select = document.createElement("button");
const icon = document.createElement("span");
const copy = document.createElement("span");
const title = document.createElement("span");
const meta = document.createElement("span");
const actions = document.createElement("span");
const children = Array.isArray(node.children) ? node.children : [];
const hasChildren = children.length > 0;
const isOpen = state.knowledgeOpenIds.has(node.id) || node.id === state.knowledgeSelectedId || children.some((child) => child.id === state.knowledgeSelectedId);
item.className = "knowledge-tree-item";
item.style.setProperty("--depth", String(depth));
row.className = "knowledge-tree-row";
row.dataset.active = String(node.id === state.knowledgeSelectedId);
row.dataset.disabled = String(node.enabled === false);
select.className = "knowledge-tree-select";
select.type = "button";
icon.className = "knowledge-tree-icon";
icon.textContent = hasChildren ? (isOpen ? "▾" : "") : node.useContent === false ? "G" : "A";
copy.className = "knowledge-tree-copy";
title.className = "knowledge-tree-title";
title.textContent = node.title || node.id;
meta.className = "knowledge-tree-meta";
meta.textContent = node.useContent === false ? "группа" : hasChildren ? "страница + ветка" : "статья";
copy.append(title, meta);
select.append(icon, copy);
select.addEventListener("click", () => {
if (hasChildren) setKnowledgeNodeOpen(node.id, !isOpen);
selectKnowledgeNode(node.id);
});
actions.className = "knowledge-tree-actions";
[
["+", "Добавить дочерний материал", () => addKnowledgeNode(node.id)],
["↑", "Поднять", () => moveKnowledgeNode(node.id, -1)],
["↓", "Опустить", () => moveKnowledgeNode(node.id, 1)],
[FINDER_TRASH_ICON_SVG, "Удалить", () => deleteKnowledgeNode(node.id)],
].forEach(([symbol, titleText, handler]) => {
const button = document.createElement("button");
button.type = "button";
button.className = "knowledge-tree-action";
button.title = titleText;
button.setAttribute("aria-label", titleText);
if (titleText === "Удалить") button.innerHTML = symbol;
else button.textContent = symbol;
button.addEventListener("click", (event) => {
event.stopPropagation();
Promise.resolve(handler()).catch((error) => setStatus(`Ошибка дерева: ${error.message}`));
});
actions.append(button);
});
row.append(select, actions);
item.append(row);
if (hasChildren && isOpen) {
const childList = document.createElement("div");
childList.className = "knowledge-tree-children";
children.forEach((child) => childList.append(renderKnowledgeTreeItem(child, depth + 1)));
item.append(childList);
}
return item;
}
function renderKnowledgeRootItem() {
const item = document.createElement("div");
const row = document.createElement("div");
const select = document.createElement("button");
const icon = document.createElement("span");
const copy = document.createElement("span");
const title = document.createElement("span");
const meta = document.createElement("span");
const actions = document.createElement("span");
const add = document.createElement("button");
item.className = "knowledge-tree-item knowledge-root-item";
item.style.setProperty("--depth", "0");
row.className = "knowledge-tree-row";
row.dataset.active = String(isKnowledgeRootSelected());
row.dataset.disabled = "false";
select.className = "knowledge-tree-select";
select.type = "button";
icon.className = "knowledge-tree-icon";
icon.textContent = "K";
copy.className = "knowledge-tree-copy";
title.className = "knowledge-tree-title";
title.textContent = knowledgeRootTitle();
meta.className = "knowledge-tree-meta";
meta.textContent = "/knowledge/ главная";
copy.append(title, meta);
select.append(icon, copy);
select.addEventListener("click", () => selectKnowledgeNode(KNOWLEDGE_ROOT_ID));
actions.className = "knowledge-tree-actions";
add.type = "button";
add.className = "knowledge-tree-action";
add.title = "Добавить раздел в корень";
add.setAttribute("aria-label", "Добавить раздел в корень");
add.textContent = "+";
add.addEventListener("click", (event) => {
event.stopPropagation();
addKnowledgeNode(null);
});
actions.append(add);
row.append(select, actions);
item.append(row);
return item;
}
function renderKnowledgeRegions() {
el.regions.innerHTML = "";
const group = document.createElement("section");
const title = document.createElement("h2");
const root = document.createElement("div");
group.className = "region-group knowledge-region";
title.className = "region-title";
title.textContent = "База знаний";
root.className = "knowledge-tree";
root.append(renderKnowledgeRootItem());
if (!knowledgeTree().length) {
const empty = document.createElement("div");
empty.className = "empty-note knowledge-empty-tree";
empty.textContent = "Дерево пустое. Нажмите плюс сверху, чтобы добавить первый раздел.";
root.append(empty);
} else {
knowledgeTree().forEach((node) => root.append(renderKnowledgeTreeItem(node)));
}
group.append(title, root);
el.regions.append(group);
}
function updateKnowledgeNodeField(node, path, value, message = "Материал базы знаний обновлён.") {
setByPath(node, path, value);
markDirty(message);
}
function createKnowledgeInput({ node, path, label, kind = "text", wide = false, placeholder = "" }) {
const field = document.createElement("label");
const labelRow = document.createElement("span");
const labelText = document.createElement("span");
const kindLabel = document.createElement("span");
const input = kind === "textarea" ? document.createElement("textarea") : document.createElement("input");
const pathLabel = document.createElement("span");
field.className = `field-control${wide || kind === "textarea" ? " wide" : ""}`;
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = label;
kindLabel.className = "field-kind";
kindLabel.textContent = kind;
input.value = getByPath(node, path) ?? "";
input.placeholder = placeholder;
input.dataset.knowledgePath = path;
if (input.tagName === "INPUT") {
input.type = kind === "url" ? "url" : "text";
input.autocomplete = "off";
}
input.addEventListener("input", () => {
updateKnowledgeNodeField(node, path, input.value);
if (path === "title" || path === "settings.navTitle") renderKnowledgeRegions();
});
pathLabel.className = "field-path";
pathLabel.textContent = path;
labelRow.append(labelText, kindLabel);
field.append(labelRow, input, pathLabel);
return field;
}
function createKnowledgeCheckbox({ node, path, label, checkedDefault = true }) {
const field = document.createElement("label");
const labelRow = document.createElement("span");
const labelText = document.createElement("span");
const kindLabel = document.createElement("span");
const input = document.createElement("input");
const pathLabel = document.createElement("span");
field.className = "field-control boolean-field";
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = label;
kindLabel.className = "field-kind";
kindLabel.textContent = "boolean";
input.type = "checkbox";
const value = getByPath(node, path);
input.checked = value == null ? checkedDefault : value !== false;
input.addEventListener("change", () => {
updateKnowledgeNodeField(node, path, input.checked);
renderKnowledgeEditor();
renderKnowledgeRegions();
});
pathLabel.className = "field-path";
pathLabel.textContent = path;
labelRow.append(labelText, kindLabel);
field.append(labelRow, input, pathLabel);
return field;
}
function knowledgeSelectionNodeInsideEditor(editor, node) {
if (!editor || !node) return false;
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return Boolean(element && editor.contains(element));
}
function saveKnowledgeEditorSelection() {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const editor = el.knowledgeForm.querySelector("[data-knowledge-richtext]");
if (!knowledgeSelectionNodeInsideEditor(editor, selection.anchorNode) || !knowledgeSelectionNodeInsideEditor(editor, selection.focusNode)) return;
state.knowledgeSavedSelection = selection.getRangeAt(0).cloneRange();
}
function restoreKnowledgeEditorSelection(editor) {
editor.focus();
let range = state.knowledgeSavedSelection;
if (!range || !knowledgeSelectionNodeInsideEditor(editor, range.commonAncestorContainer)) {
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
state.knowledgeSavedSelection = range.cloneRange();
}
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return range;
}
function syncKnowledgeRichText(node, editor) {
setByPath(node, editor.dataset.knowledgeBodyPath || "bodyHtml", editor.innerHTML.trim());
markDirty("Текст статьи обновлён.");
}
function execKnowledgeCommand(editor, node, command, value = null) {
restoreKnowledgeEditorSelection(editor);
document.execCommand(command, false, value);
syncKnowledgeRichText(node, editor);
}
function insertKnowledgeHtml(editor, node, html) {
restoreKnowledgeEditorSelection(editor);
document.execCommand("insertHTML", false, html);
saveKnowledgeEditorSelection();
syncKnowledgeRichText(node, editor);
}
function escapeKnowledgeShortcodeAttr(value) {
return String(value ?? "")
.replace(/\s+/g, " ")
.trim()
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("[", "(")
.replaceAll("]", ")");
}
function normalizeKnowledgeLandingTarget(value) {
return String(value || "")
.trim()
.replace(/^#+/, "")
.replace(/^index\.html#/i, "")
.replace(/^\/?#/, "");
}
function normalizeKnowledgeButtonLink(value) {
return (String(value ?? "").trim() || "/").replace(/^\/+(https?:\/\/)/i, "$1").replace(/^\/+((?:mailto|tel):)/i, "$1");
}
function knowledgeButtonShortcode({ name, link, target, blockId }) {
const safeName = escapeKnowledgeShortcodeAttr(name || "Посмотреть");
const safeLink = escapeKnowledgeShortcodeAttr(normalizeKnowledgeButtonLink(link));
const safeTarget = normalizeKnowledgeLandingTarget(target);
const safeBlockId = String(blockId || "").trim();
const blockPart = safeBlockId ? ` block="${escapeKnowledgeShortcodeAttr(safeBlockId)}"` : "";
const targetPart = safeTarget ? ` target="${escapeKnowledgeShortcodeAttr(safeTarget)}"` : "";
return `[button name="${safeName}" link="${safeLink}"${blockPart}${targetPart}]`;
}
function renderKnowledgeButtonTargetOptions() {
if (!el.knowledgeButtonTargetOptions) return;
el.knowledgeButtonTargetOptions.innerHTML = "";
const added = new Set();
for (const { id, label, target } of landingBlockTargets()) {
const targetKey = target.toLowerCase();
if (!added.has(targetKey)) {
const option = document.createElement("option");
option.value = target;
option.label = `${label} / ${id}`;
el.knowledgeButtonTargetOptions.append(option);
added.add(targetKey);
}
const idKey = String(id || "").toLowerCase();
if (id && !added.has(idKey)) {
const option = document.createElement("option");
option.value = id;
option.label = `${label} / block id`;
el.knowledgeButtonTargetOptions.append(option);
added.add(idKey);
}
}
}
function setKnowledgeButtonError(message = "") {
if (el.knowledgeButtonError) el.knowledgeButtonError.textContent = message;
}
function closeKnowledgeButtonModal(value = null) {
const resolve = state.knowledgeButtonResolve;
state.knowledgeButtonResolve = null;
el.knowledgeButtonModal.classList.add("hidden");
setKnowledgeButtonError("");
if (resolve) resolve(value);
}
function confirmKnowledgeButtonModal() {
const name = el.knowledgeButtonName.value.trim();
if (!name) {
setKnowledgeButtonError("Название кнопки обязательно.");
el.knowledgeButtonName.focus();
return;
}
const link = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value);
el.knowledgeButtonLink.value = link;
closeKnowledgeButtonModal({
name,
link,
targetInput: el.knowledgeButtonTarget.value.trim(),
});
}
function requestKnowledgeButtonInput({ name = "Посмотреть демо", link = "/", target = "" } = {}) {
if (state.knowledgeButtonResolve) closeKnowledgeButtonModal(null);
return new Promise((resolve) => {
state.knowledgeButtonResolve = resolve;
renderKnowledgeButtonTargetOptions();
setKnowledgeButtonError("");
el.knowledgeButtonName.value = name;
el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(link);
el.knowledgeButtonTarget.value = normalizeKnowledgeLandingTarget(target);
el.knowledgeButtonModal.classList.remove("hidden");
el.knowledgeButtonName.focus();
el.knowledgeButtonName.select();
});
}
function openKnowledgeMediaPicker(editor, node) {
saveKnowledgeEditorSelection();
const fakeInput = { value: "" };
const fakeText = { textContent: "", title: "" };
const fakePreview = document.createElement("div");
const fakeHint = document.createElement("span");
const fakeError = document.createElement("span");
const fakeUrlInput = { value: "" };
openMediaLibrary({
block: { id: node.id || "knowledge" },
editableField: { path: "content.bodyHtml", label: "Изображение статьи" },
hiddenInput: fakeInput,
urlInput: fakeUrlInput,
fileName: fakeText,
preview: fakePreview,
hint: fakeHint,
error: fakeError,
setSource() {},
onSelect(url) {
const safeUrl = String(url || "");
if (!safeUrl) return;
insertKnowledgeHtml(editor, node, `<img src="${escapeHtml(safeUrl)}" alt="" loading="lazy">`);
},
}).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`));
}
function renderKnowledgeToolbar(editor, node) {
const toolbar = document.createElement("div");
toolbar.className = "knowledge-rich-toolbar";
const buttons = [
["P", "Абзац", () => execKnowledgeCommand(editor, node, "formatBlock", "P")],
["H2", "Заголовок H2", () => execKnowledgeCommand(editor, node, "formatBlock", "H2")],
["H3", "Заголовок H3", () => execKnowledgeCommand(editor, node, "formatBlock", "H3")],
["B", "Жирный", () => execKnowledgeCommand(editor, node, "bold")],
["I", "Курсив", () => execKnowledgeCommand(editor, node, "italic")],
["UL", "Список", () => execKnowledgeCommand(editor, node, "insertUnorderedList")],
["OL", "Нумерованный список", () => execKnowledgeCommand(editor, node, "insertOrderedList")],
["Link", "Ссылка", () => {
const href = window.prompt("Адрес ссылки", "/knowledge/");
if (!href) return;
execKnowledgeCommand(editor, node, "createLink", href);
}],
["Button", "Кнопка", async () => {
saveKnowledgeEditorSelection();
const values = await requestKnowledgeButtonInput();
if (!values) return;
const targetInput = values.targetInput;
const landingTarget = findLandingTargetByInput(targetInput);
const target = landingTarget ? landingTarget.target : targetInput;
const blockId = landingTarget?.id || "";
insertKnowledgeHtml(
editor,
node,
`<p>${escapeHtml(knowledgeButtonShortcode({ name: values.name, link: values.link, target, blockId }))}</p>`,
);
}],
["Image", "Изображение", () => openKnowledgeMediaPicker(editor, node)],
];
buttons.forEach(([label, title, action]) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.title = title;
button.addEventListener("mousedown", (event) => event.preventDefault());
button.addEventListener("click", action);
toolbar.append(button);
});
return toolbar;
}
function renderKnowledgeEditor() {
const isRoot = isKnowledgeRootSelected();
const node = selectedKnowledgeNode();
el.empty.classList.add("hidden");
el.form.classList.add("hidden");
el.projectSettingsForm.classList.add("hidden");
el.knowledgeForm.classList.remove("hidden");
el.knowledgeForm.innerHTML = "";
if (!state.knowledge) {
el.knowledgeForm.innerHTML = '<div class="empty-note">Загружаем базу знаний...</div>';
return;
}
if (!node) {
const empty = document.createElement("section");
empty.className = "content-panel";
empty.innerHTML = "<h3>Дерево базы знаний пустое</h3><p class=\"group-desc\">Нажмите плюс в левом верхнем углу, чтобы создать первый раздел.</p>";
el.knowledgeForm.append(empty);
return;
}
state.knowledge.settings ||= {};
const bodyPath = isRoot ? "introHtml" : "bodyHtml";
const match = isRoot ? null : findKnowledgeNode(node.id);
const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний";
el.selectedRegion.textContent = isRoot
? `NODE.DC / ${knowledgeRootTitle()} / /knowledge/`
: `NODE.DC / ${state.knowledge.title || "База знаний"} / ${parentTitle}`;
el.selectedTitle.textContent = isRoot ? knowledgeRootTitle() : node.title || "Материал";
const metaPanel = document.createElement("section");
const metaHead = document.createElement("div");
const metaTitle = document.createElement("h3");
const metaKicker = document.createElement("div");
const grid = document.createElement("div");
metaPanel.className = "content-panel";
metaHead.className = "panel-head";
metaKicker.className = "section-kicker";
metaKicker.textContent = isRoot ? "Корневая страница" : "Узел дерева";
metaTitle.textContent = isRoot ? "Настройки /knowledge/" : "Параметры материала";
metaHead.append(Object.assign(document.createElement("div"), { innerHTML: `${metaKicker.outerHTML}<h3>${metaTitle.textContent}</h3>` }));
grid.className = "group-grid";
if (isRoot) {
grid.append(
createKnowledgeInput({ node, path: "title", label: "H1 / название в sidebar", wide: false }),
createKnowledgeInput({ node, path: "settings.navTitle", label: "Название в шапке", wide: false }),
createKnowledgeInput({ node, path: "settings.searchPlaceholder", label: "Placeholder поиска", wide: false }),
createKnowledgeInput({ node, path: "settings.homeHref", label: "Ссылка логотипа", kind: "url", wide: false }),
createKnowledgeInput({ node, path: "settings.themeDefault", label: "Тема по умолчанию", wide: false }),
createKnowledgeCheckbox({ node, path: "settings.downloadEnabled", label: "Показывать Download", checkedDefault: false }),
);
} else {
grid.append(
createKnowledgeInput({ node, path: "title", label: "Название", wide: false }),
createKnowledgeInput({ node, path: "slug", label: "Slug", wide: false }),
createKnowledgeCheckbox({ node, path: "enabled", label: "Публиковать" }),
createKnowledgeCheckbox({ node, path: "useContent", label: "Использовать как информационную страницу" }),
);
}
metaPanel.append(metaHead, grid);
const seoPanel = document.createElement("section");
const seoHead = document.createElement("div");
const seoGrid = document.createElement("div");
seoPanel.className = "content-panel";
seoHead.className = "panel-head";
seoHead.innerHTML = `<div><div class="section-kicker">SEO</div><h3>${isRoot ? "Сниппет раздела" : "Сниппет страницы"}</h3></div><span class="panel-note">Поля попадут в title, description, Open Graph и sitemap.</span>`;
seoGrid.className = "group-grid";
if (isRoot) {
seoGrid.append(
createKnowledgeInput({ node, path: "settings.description", label: "SEO description / описание", kind: "textarea", wide: true }),
);
} else {
node.seo ||= {};
seoGrid.append(
createKnowledgeInput({ node, path: "seo.title", label: "SEO title", wide: true }),
createKnowledgeInput({ node, path: "seo.description", label: "SEO description", kind: "textarea", wide: true }),
);
}
seoPanel.append(seoHead, seoGrid);
const contentPanel = document.createElement("section");
const contentHead = document.createElement("div");
const editor = document.createElement("div");
contentPanel.className = "content-panel knowledge-rich-panel";
contentHead.className = "panel-head";
contentHead.innerHTML = `<div><div class="section-kicker">Контент</div><h3>${isRoot ? "Текст главной страницы" : "Текст статьи"}</h3></div><span class="panel-note">H2/H3 формируют правый список “На этой странице”.</span>`;
editor.className = "knowledge-rich-editor";
editor.contentEditable = isRoot || node.useContent !== false ? "true" : "false";
editor.dataset.knowledgeRichtext = "true";
editor.dataset.knowledgeBodyPath = bodyPath;
editor.innerHTML = getByPath(node, bodyPath) || "<p></p>";
editor.addEventListener("focusout", () => saveKnowledgeEditorSelection());
editor.addEventListener("keyup", () => saveKnowledgeEditorSelection());
editor.addEventListener("mouseup", () => saveKnowledgeEditorSelection());
editor.addEventListener("input", () => syncKnowledgeRichText(node, editor));
contentPanel.append(contentHead, renderKnowledgeToolbar(editor, node), editor);
if (!isRoot && node.useContent === false) {
const disabledNote = document.createElement("p");
disabledNote.className = "group-desc";
disabledNote.textContent = "Контент сохранится в модели, но публичная страница для этого узла не будет генерироваться, пока флаг выключен.";
contentPanel.append(disabledNote);
}
const jsonDetails = document.createElement("details");
jsonDetails.className = "json-details knowledge-json";
jsonDetails.innerHTML = `<summary>${isRoot ? "JSON всей базы знаний" : "JSON выбранного материала"}</summary>`;
const jsonField = document.createElement("label");
const json = document.createElement("textarea");
jsonField.className = "json-field";
json.value = JSON.stringify(node, null, 2);
json.addEventListener("blur", () => {
try {
const parsed = JSON.parse(json.value);
if (isRoot) {
state.knowledge = parsed;
state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID;
} else {
const match = findKnowledgeNode(node.id);
if (!match) return;
match.siblings[match.index] = parsed;
state.knowledgeSelectedId = parsed.id || node.id;
}
renderKnowledgeAll();
markDirty(isRoot ? "JSON базы знаний применён локально." : "JSON материала применён локально.");
} catch (error) {
setStatus(`Ошибка JSON ${isRoot ? "базы знаний" : "материала"}: ${error.message}`);
}
});
jsonField.innerHTML = "<span>Техническое представление</span>";
jsonField.append(json);
jsonDetails.append(jsonField);
el.knowledgeForm.append(metaPanel, seoPanel, contentPanel, jsonDetails);
}
function renderKnowledgeAll() {
updateAdminModeSurface();
ensureKnowledgeSelection();
renderKnowledgeRegions();
renderKnowledgeEditor();
}
async function loadKnowledge() {
if (!siteReady()) {
renderSiteUnavailable();
return;
}
const response = await fetch("/api/knowledge");
if (!response.ok) throw new Error(await response.text());
state.knowledge = await response.json();
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
ensureKnowledgeSelection();
renderAll();
setDirty(false);
setStatus("Модель knowledge.json загружена.");
}
async function persistKnowledge() {
const response = await fetch("/api/knowledge", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(state.knowledge),
});
if (!response.ok) throw new Error(await response.text());
}
async function saveKnowledge() {
await persistKnowledge();
setDirty(false);
setStatus("База знаний сохранена в content/knowledge.json.");
}
async function renderKnowledge() {
await persistKnowledge();
const response = await fetch("/api/render/knowledge", { 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}` : "База знаний сохранена и пересобрана.");
}
async function loadPage() {
const projectConfig = await loadProjectConfig();
state.projectConfig = projectConfig;
state.knowledge = null;
if (!siteReady()) {
state.page = null;
state.templates = [];
state.selected = null;
renderSiteUnavailable();
setStatus("Сайт не загружен в локальный workspace CMS.");
return;
}
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();
ensureSeoModel();
state.selected = { kind: "static" };
renderAll();
setDirty(false);
setStatus("Модель home.json загружена.");
if (state.mode === "knowledge" && !state.knowledge) {
await loadKnowledge();
}
}
async function persistPage() {
if (activeBlock()) syncBlockFromFields();
assertUniqueLandingTargets();
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} — копия`;
if (duplicate.anchor) {
duplicate.anchor = uniqueLandingTarget(duplicate.anchor, duplicate);
}
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} — вставка`;
if (pasted.anchor) {
pasted.anchor = uniqueLandingTarget(pasted.anchor, pasted);
}
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».");
}
function openProjectSettings() {
state.selected = { kind: "project-settings" };
renderAll();
}
for (const input of [el.id, el.label, el.anchor, el.enabled]) {
input.addEventListener("input", syncBlockFromFields);
input.addEventListener("change", syncBlockFromFields);
}
el.modeContent?.addEventListener("click", () => setAdminMode("content"));
el.modeSeo?.addEventListener("click", () => setAdminMode("seo"));
el.modeKnowledge?.addEventListener("click", () => setAdminMode("knowledge"));
el.addProject?.addEventListener("click", openProjectSettings);
el.projectSettings?.addEventListener("click", openProjectSettings);
el.json.addEventListener("blur", () => {
if (state.mode === "knowledge") return;
if (state.filePickerActive || state.mediaUploadActive) {
recoverEditorSurface();
return;
}
syncBlockFromJson();
});
el.addBlock?.addEventListener("click", () => {
if (state.mode === "knowledge") {
addKnowledgeNode(null);
return;
}
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.knowledgeButtonClose.addEventListener("click", () => closeKnowledgeButtonModal(null));
el.knowledgeButtonCancel.addEventListener("click", () => closeKnowledgeButtonModal(null));
el.knowledgeButtonConfirm.addEventListener("click", confirmKnowledgeButtonModal);
el.knowledgeButtonLink.addEventListener("blur", () => {
el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value);
});
el.knowledgeButtonModal.addEventListener("click", (event) => {
if (event.target === el.knowledgeButtonModal) closeKnowledgeButtonModal(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("selectionchange", () => {
if (state.mode !== "knowledge") return;
saveKnowledgeEditorSelection();
});
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.knowledgeButtonModal.classList.contains("hidden")) {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
confirmKnowledgeButtonModal();
}
if (event.key === "Enter" && event.target !== el.knowledgeButtonTarget) {
event.preventDefault();
confirmKnowledgeButtonModal();
}
if (event.key === "Escape") {
event.preventDefault();
closeKnowledgeButtonModal(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", () => {
if (isProjectSettingsSelected()) {
loadProjectConfig().then(renderProjectSettings).then(() => setStatus("Настройки проекта перезагружены.")).catch((error) => setStatus(error.message));
return;
}
const action = state.mode === "knowledge" ? loadKnowledge : loadPage;
action().catch((error) => setStatus(error.message));
});
el.save.addEventListener("click", () => {
if (isProjectSettingsSelected()) {
saveProjectSettings().catch((error) => setStatus(error.message));
return;
}
const action = state.mode === "knowledge" ? saveKnowledge : savePage;
action().catch((error) => setStatus(error.message));
});
el.render.addEventListener("click", () => {
if (isProjectSettingsSelected()) {
checkProjectConnection().catch((error) => setStatus(error.message));
return;
}
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
action().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));