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"),
publish: document.querySelector("#publish"),
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: 'ДАЛЕЕ — ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
enabled: true,
eyebrowHtml: 'ДАЛЕЕ — ИЮЛЬ 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 =
'';
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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
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 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 || "не рассчитан"}.`);
}
async function publishProject() {
const response = await fetch("/api/project/publish", { method: "POST" });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось опубликовать проект");
}
setStatus(publishStatusText(payload).trim() || "Проект опубликован.");
}
function publishStatusText(payload) {
const publish = payload?.publish;
if (!publish) return "";
const uploaded = Number(publish.uploadedFiles || 0).toLocaleString("ru-RU");
const skipped = Number(publish.skippedFiles || 0).toLocaleString("ru-RU");
const total = Number(publish.totalFiles || 0).toLocaleString("ru-RU");
const volume = publish.uploadedLabel || "0 МБ";
return ` Опубликовано на ${publish.host}: загружено ${uploaded}/${total}, пропущено ${skipped}, объем ${volume}.`;
}
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.publish) {
el.publish.classList.add("hidden");
el.publish.disabled = true;
}
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 =
'';
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;
const onProjectFieldChange = () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
if (path === "deploy.enabled") renderProjectSettings();
};
control.addEventListener("input", onProjectFieldChange);
control.addEventListener("change", onProjectFieldChange);
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;
}
async function uploadSiteArchive(file) {
if (!file) return;
const formData = new FormData();
formData.append("file", file, file.name);
setStatus(`Загружаем архив сайта: ${file.name}...`);
const response = await fetch("/api/project/upload-site", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось загрузить архив сайта");
}
state.projectConfig = await loadProjectConfig();
renderProjectSettings();
setStatus(`Архив сайта загружен: ${Number(payload.files || 0).toLocaleString("ru-RU")} файлов, workspace ${payload.size?.label || payload.label || "обновлен"}.`);
}
function renderProjectArchiveUpload() {
const field = document.createElement("div");
const title = document.createElement("span");
const badge = document.createElement("span");
const body = document.createElement("div");
const button = document.createElement("button");
const input = document.createElement("input");
const note = document.createElement("small");
field.className = "field-control project-archive-field";
title.textContent = "Архив сайта";
badge.className = "field-kind";
badge.textContent = "ZIP";
body.className = "project-archive-control";
button.className = "ghost-btn project-archive-button";
button.type = "button";
button.textContent = "Загрузить архив сайта";
input.type = "file";
input.accept = ".zip,application/zip";
input.className = "hidden";
note.className = "field-hint";
note.textContent = "ZIP должен содержать content/pages/home.json, content/knowledge.json, templates и tools рендера.";
button.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
uploadSiteArchive(file).catch((error) => setStatus(error.message));
});
body.append(button, input);
field.append(title, badge, body, note);
return field;
}
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 = `
${escapeHtml(icon)}
${escapeHtml(title)}
${escapeHtml(body)}
`;
}
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 = 'Загружаем настройки проекта...
';
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 = "Проверить доступ к модели, базе знаний и файлам проекта";
}
if (el.publish) {
const enabled = Boolean(state.projectConfig?.deploy?.enabled);
el.publish.classList.toggle("hidden", !enabled);
el.publish.disabled = !enabled || !siteReady() || state.projectConfigDirty;
el.publish.title = state.projectConfigDirty
? "Сначала сохраните настройки проекта"
: enabled
? "Опубликовать текущую копию сайта на хостинг"
: "Включите деплой в настройках проекта";
}
const projectCard = createProjectSettingsCard({
kicker: "Проект",
title: "Подключенный сайт",
note: "Проект хранится в workspace CMS. Архив сайта загружается один раз и дальше редактируется через админку.",
fields: [
createProjectField({ path: "name", label: "Название проекта" }),
createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }),
renderProjectArchiveUpload(),
],
});
const deployCard = createProjectSettingsCard({
kicker: "Публикация",
title: "Деплой на хостинг",
note: "Укажите доступ к хостингу. Пароль сохраняется на сервере в зашифрованном виде и не возвращается в браузер.",
fields: [
createProjectField({ path: "deploy.enabled", 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.password",
label: "Пароль",
type: "password",
placeholder: state.projectConfig?.deploy?.passwordConfigured ? "Пароль сохранен. Введите новый для замены" : "Введите пароль",
hint: state.projectConfig?.deploy?.passwordConfigured
? "Пароль уже сохранен на сервере. Поле пустое, потому что секрет не возвращается в браузер."
: "Введите пароль один раз и нажмите «Сохранить настройки».",
}),
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 = "DIR";
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 =
'';
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: "Новый материал базы знаний.
",
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("&", "&")
.replaceAll('"', """)
.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, `
`);
},
}).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,
`${escapeHtml(knowledgeButtonShortcode({ name: values.name, link: values.link, target, blockId }))}
`,
);
}],
["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 = 'Загружаем базу знаний...
';
return;
}
if (!node) {
const empty = document.createElement("section");
empty.className = "content-panel";
empty.innerHTML = "Дерево базы знаний пустое
Нажмите плюс в левом верхнем углу, чтобы создать первый раздел.
";
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}${metaTitle.textContent}
` }));
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 = `SEO
${isRoot ? "Сниппет раздела" : "Сниппет страницы"}
Поля попадут в title, description, Open Graph и sitemap.`;
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 = `Контент
${isRoot ? "Текст главной страницы" : "Текст статьи"}
H2/H3 формируют правый список “На этой странице”.`;
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) || "";
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 = `${isRoot ? "JSON всей базы знаний" : "JSON выбранного материала"}`;
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 = "Техническое представление";
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}` : "База знаний сохранена и пересобрана."}${publishStatusText(payload)}`,
);
}
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 пересобран."}${publishStatusText(payload)}`,
);
}
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.publish?.addEventListener("click", () => {
if (!isProjectSettingsSelected()) return;
publishProject().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));