5443 lines
201 KiB
JavaScript
5443 lines
201 KiB
JavaScript
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,
|
||
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"),
|
||
sidebarTitle: document.querySelector("#sidebar-title"),
|
||
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"),
|
||
addBlock: document.querySelector("#add-block"),
|
||
reload: document.querySelector("#reload"),
|
||
save: document.querySelector("#save"),
|
||
render: document.querySelector("#render"),
|
||
dirtyState: document.querySelector("#dirty-state"),
|
||
status: document.querySelector("#status"),
|
||
empty: document.querySelector("#empty-state"),
|
||
form: document.querySelector("#block-form"),
|
||
knowledgeForm: document.querySelector("#knowledge-form"),
|
||
selectedRegion: document.querySelector("#selected-region"),
|
||
selectedTitle: document.querySelector("#selected-title"),
|
||
id: document.querySelector("#block-id"),
|
||
label: document.querySelector("#block-label"),
|
||
template: document.querySelector("#block-template"),
|
||
anchor: document.querySelector("#block-anchor"),
|
||
targetOptions: document.querySelector("#block-target-options"),
|
||
enabled: document.querySelector("#block-enabled"),
|
||
contentFields: document.querySelector("#content-fields"),
|
||
json: document.querySelector("#block-json"),
|
||
moveUp: document.querySelector("#move-up"),
|
||
moveDown: document.querySelector("#move-down"),
|
||
duplicate: document.querySelector("#duplicate"),
|
||
copy: document.querySelector("#copy"),
|
||
pasteAfter: document.querySelector("#paste-after"),
|
||
deleteBlock: document.querySelector("#delete-block"),
|
||
templateModal: document.querySelector("#template-modal"),
|
||
templateList: document.querySelector("#template-list"),
|
||
templateModalClose: document.querySelector("#template-modal-close"),
|
||
templateModalCancel: document.querySelector("#template-modal-cancel"),
|
||
deleteModal: document.querySelector("#delete-modal"),
|
||
deleteModalTitle: document.querySelector("#delete-modal-title"),
|
||
deleteModalBody: document.querySelector("#delete-modal-body"),
|
||
deleteModalCancel: document.querySelector("#delete-modal-cancel"),
|
||
deleteModalConfirm: document.querySelector("#delete-modal-confirm"),
|
||
mediaModal: document.querySelector("#media-modal"),
|
||
mediaModalTitle: document.querySelector("#media-modal-title"),
|
||
mediaModalSubtitle: document.querySelector("#media-modal-subtitle"),
|
||
mediaSiteSize: document.querySelector("#media-site-size"),
|
||
mediaModalClose: document.querySelector("#media-modal-close"),
|
||
mediaModalCancel: document.querySelector("#media-modal-cancel"),
|
||
mediaTrashToggle: document.querySelector("#media-trash-toggle"),
|
||
mediaSearch: document.querySelector("#media-search"),
|
||
mediaKindFilters: document.querySelector("#media-kind-filters"),
|
||
mediaRootFilters: document.querySelector("#media-root-filters"),
|
||
mediaUpload: document.querySelector("#media-upload"),
|
||
mediaRefresh: document.querySelector("#media-refresh"),
|
||
mediaDropZone: document.querySelector("#media-drop-zone"),
|
||
mediaBreadcrumbs: document.querySelector("#media-breadcrumbs"),
|
||
mediaList: document.querySelector("#media-list"),
|
||
mediaEmpty: document.querySelector("#media-empty"),
|
||
mediaPreview: document.querySelector("#media-preview"),
|
||
mediaPreviewName: document.querySelector("#media-preview-name"),
|
||
mediaPreviewPath: document.querySelector("#media-preview-path"),
|
||
mediaPreviewMeta: document.querySelector("#media-preview-meta"),
|
||
mediaPreviewUsage: document.querySelector("#media-preview-usage"),
|
||
mediaSelect: document.querySelector("#media-select"),
|
||
mediaDelete: document.querySelector("#media-delete"),
|
||
mediaContextMenu: document.querySelector("#media-context-menu"),
|
||
mediaActionModal: document.querySelector("#media-action-modal"),
|
||
mediaActionTitle: document.querySelector("#media-action-title"),
|
||
mediaActionBody: document.querySelector("#media-action-body"),
|
||
mediaActionLabel: document.querySelector("#media-action-label"),
|
||
mediaActionInput: document.querySelector("#media-action-input"),
|
||
mediaActionCancel: document.querySelector("#media-action-cancel"),
|
||
mediaActionConfirm: document.querySelector("#media-action-confirm"),
|
||
knowledgeButtonModal: document.querySelector("#knowledge-button-modal"),
|
||
knowledgeButtonClose: document.querySelector("#knowledge-button-close"),
|
||
knowledgeButtonName: document.querySelector("#knowledge-button-name"),
|
||
knowledgeButtonLink: document.querySelector("#knowledge-button-link"),
|
||
knowledgeButtonTarget: document.querySelector("#knowledge-button-target"),
|
||
knowledgeButtonTargetOptions: document.querySelector("#knowledge-button-target-options"),
|
||
knowledgeButtonError: document.querySelector("#knowledge-button-error"),
|
||
knowledgeButtonCancel: document.querySelector("#knowledge-button-cancel"),
|
||
knowledgeButtonConfirm: document.querySelector("#knowledge-button-confirm"),
|
||
};
|
||
|
||
const STATIC_FALLBACK = {
|
||
id: "static-elements",
|
||
type: "staticElements",
|
||
adminLabel: "Статичные элементы",
|
||
enabled: true,
|
||
content: {
|
||
navigation: {
|
||
logoSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||
logoAlt: "главная",
|
||
brandLabel: "NODE.DC",
|
||
links: [
|
||
{ enabled: true, label: "О продукте", href: "", target: "index.html#platform-composition", linkMode: "target", linkTarget: "" },
|
||
{ enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" },
|
||
{
|
||
enabled: true,
|
||
label: "Документация",
|
||
href: "https://nodedc.dctouch.ru/",
|
||
target: "",
|
||
linkMode: "href",
|
||
linkTarget: "_blank",
|
||
},
|
||
{ enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-intro", linkMode: "target", linkTarget: "" },
|
||
],
|
||
},
|
||
notifications: [
|
||
{
|
||
enabled: true,
|
||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||
eyebrowClass: "red-txt gray",
|
||
title: "Открытая бета",
|
||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||
},
|
||
{
|
||
enabled: true,
|
||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
||
eyebrowClass: "red-txt gray",
|
||
title: "Альфа для Founder-доступа",
|
||
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||
},
|
||
{
|
||
enabled: true,
|
||
eyebrowHtml: "СЕЙЧАС",
|
||
eyebrowClass: "red-txt",
|
||
title: "Инвайты в закрытую бету уже отправлены.",
|
||
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
||
},
|
||
],
|
||
dock: {
|
||
items: [
|
||
{
|
||
enabled: true,
|
||
title: "NODE.DC",
|
||
href: "javascript:void(0)",
|
||
linkTarget: "",
|
||
targetId: "app-data",
|
||
iconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||
iconAlt: "Иконка NODE.DC",
|
||
className: "dock-icon nodedc-app",
|
||
iconClass: "logo",
|
||
dividerBefore: false,
|
||
},
|
||
{
|
||
enabled: true,
|
||
title: "Корзина",
|
||
href: "javascript:void(0)",
|
||
linkTarget: "",
|
||
targetId: "",
|
||
iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif",
|
||
iconAlt: "Белая иконка корзины.",
|
||
className: "dock-icon no-bg",
|
||
iconClass: "dock-app-img trash",
|
||
dividerBefore: true,
|
||
},
|
||
],
|
||
demoFileLabel: "демо-видео.txt",
|
||
},
|
||
assets: {
|
||
webglModelSrc: "./assets/custom/icon.glb",
|
||
logoPng: "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png",
|
||
logoPngAlt: "Логотип NODE.DC",
|
||
appIconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||
appIconAlt: "Иконка NODE.DC",
|
||
appWordmarkSrc: "./assets/site/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
||
appWordmarkAlt: "Логотип NODE.DC",
|
||
faviconSvg: "./assets/uploads/favicon/icon-adaptive.svg",
|
||
faviconIco: "./assets/uploads/favicon/favicon.ico",
|
||
faviconAppleTouchIcon: "./assets/uploads/favicon/apple-touch-icon.png",
|
||
faviconPng192: "./assets/uploads/favicon/icon-192.png",
|
||
faviconPng512: "./assets/uploads/favicon/icon-512.png",
|
||
faviconManifest: "./assets/uploads/favicon/manifest.webmanifest.json",
|
||
},
|
||
},
|
||
editableFields: [
|
||
field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"),
|
||
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"),
|
||
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"),
|
||
collection(
|
||
"content.navigation.links",
|
||
"Кнопки шапки",
|
||
"Шапка: ссылки",
|
||
{ enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" },
|
||
[
|
||
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
|
||
field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"),
|
||
],
|
||
),
|
||
collection(
|
||
"content.notifications",
|
||
"Уведомления",
|
||
"Уведомления",
|
||
{
|
||
enabled: true,
|
||
eyebrowHtml: "СЕЙЧАС",
|
||
eyebrowClass: "red-txt gray",
|
||
title: "Новое уведомление",
|
||
body: "Текст уведомления.",
|
||
},
|
||
[
|
||
field("eyebrowHtml", "Дата / статус", "html", "html", "Уведомления"),
|
||
field("eyebrowClass", "CSS-класс статуса", "text", "attr", "Уведомления"),
|
||
field("title", "Заголовок", "text", "text", "Уведомления"),
|
||
field("body", "Текст", "textarea", "text", "Уведомления"),
|
||
],
|
||
),
|
||
collection(
|
||
"content.dock.items",
|
||
"Иконки dock-панели",
|
||
"Dock",
|
||
{
|
||
enabled: true,
|
||
title: "Новое приложение",
|
||
href: "javascript:void(0)",
|
||
linkTarget: "",
|
||
targetId: "",
|
||
iconSrc: "",
|
||
iconAlt: "",
|
||
className: "dock-icon nodedc-app",
|
||
iconClass: "",
|
||
dividerBefore: false,
|
||
},
|
||
[
|
||
field("enabled", "Отображение", "boolean", "boolean", "Dock"),
|
||
field("title", "Название", "text", "text", "Dock"),
|
||
field("href", "Ссылка / адрес", "url", "attr", "Dock"),
|
||
field("linkTarget", "Target ссылки", "text", "attr", "Dock"),
|
||
field("targetId", "ID окна / приложения", "text", "attr", "Dock"),
|
||
field("iconSrc", "Иконка", "media", "attr", "Dock"),
|
||
field("iconAlt", "Alt иконки", "text", "text", "Dock"),
|
||
field("dividerBefore", "Разделитель слева", "boolean", "boolean", "Dock"),
|
||
],
|
||
),
|
||
field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"),
|
||
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.logoPng", "PNG-логотип", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.logoPngAlt", "PNG-логотип: alt", "text", "text", "Модель и логотипы"),
|
||
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.appIconAlt", "Иконка приложения: alt", "text", "text", "Модель и логотипы"),
|
||
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt", "text", "text", "Модель и логотипы"),
|
||
field("content.assets.faviconSvg", "Favicon SVG", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.faviconIco", "Favicon ICO", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.faviconAppleTouchIcon", "Favicon Apple 180", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.faviconPng192", "Favicon PNG 192", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.faviconPng512", "Favicon PNG 512", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.faviconManifest", "Favicon manifest", "media", "attr", "Модель и логотипы"),
|
||
],
|
||
};
|
||
|
||
const GROUP_COPY = {
|
||
"Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.",
|
||
"Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.",
|
||
Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.",
|
||
Dock: "Нижняя системная панель: приложения, корзина, ссылки и подписи файлов.",
|
||
"Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы, favicon-комплект и alt-тексты.",
|
||
Заголовок: "Главный заголовок и вводный текст выбранной секции.",
|
||
"Верхний информационный блок": "Верхняя пара: длинный текст и крупный заголовок над медиаслайдером.",
|
||
"Нижний информационный блок": "Нижняя зеркальная пара: крупный заголовок и длинный текст под медиаслайдером.",
|
||
"Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.",
|
||
"Карточка доступа": "Единый компонент окна заявки: редактируется здесь и повторяется в нижнем блоке запроса DEMO.",
|
||
Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.",
|
||
"Юридический блок": "Политики, права и нижний текст формы.",
|
||
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
|
||
Пункты: "Текстовые пункты внутри секции.",
|
||
Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.",
|
||
Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.",
|
||
"Один клик": "Подблок про быстрое добавление и настройку компонентов.",
|
||
MCP: "Подблок про MCP-сервер и контекст NODE.DC.",
|
||
"Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.",
|
||
Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.",
|
||
FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.",
|
||
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
|
||
"Центральный текст": "Отдельный центрированный текстовый блок: надзаголовок, крупная строка и три подписи.",
|
||
Тарифы: "Окно тарифной таблицы: планы, цены, кнопки и примечания.",
|
||
"Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.",
|
||
"Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.",
|
||
"Карточки скриншотов": "Скриншоты в виде окон и файлов: каждый элемент можно скрыть, переставить, дублировать или удалить.",
|
||
"TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.",
|
||
Футер: "Брендовый блок, копирайт и базовые ассеты футера.",
|
||
"Навигация футера": "Ссылки навигационной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.",
|
||
"Контактные ссылки": "Ссылки контактной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.",
|
||
Политики: "Нижняя строка политик и условия отображения.",
|
||
Контент: "Остальные typed-поля выбранного блока.",
|
||
};
|
||
|
||
const MEDIA_ACCEPT =
|
||
"image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.ico,.avif,.webp,.webmanifest,.json,.woff,.woff2";
|
||
const MEDIA_DRAG_MIME = "application/x-nodedc-media-entry";
|
||
const MEDIA_KIND_FILTERS = [
|
||
["all", "Все"],
|
||
["image", "Изображения"],
|
||
["video", "Видео"],
|
||
["model", "3D"],
|
||
];
|
||
const MEDIA_ROOT_FILTERS = [
|
||
["", "Проект"],
|
||
["assets", "Assets"],
|
||
["assets/uploads", "Uploads"],
|
||
["assets/media", "Media"],
|
||
["assets/site/images", "NODE.DC"],
|
||
["assets/custom", "Custom"],
|
||
];
|
||
const FINDER_TRASH_ICON_SVG =
|
||
'<svg class="trash-can-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.75 6.75h16.5"></path><path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85"></path><path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15"></path><path d="M10 10.75v5.5"></path><path d="M14 10.75v5.5"></path></svg>';
|
||
const mediaFilePicker = createMediaFilePicker();
|
||
const collectionItemRuntimeKeys = new WeakMap();
|
||
let collectionItemRuntimeKeyCounter = 0;
|
||
|
||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
|
||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
|
||
}
|
||
|
||
function collection(path, label, group, itemTemplate, itemFields) {
|
||
return {
|
||
path,
|
||
label,
|
||
kind: "collection",
|
||
renderAs: "collection",
|
||
group,
|
||
itemTemplate,
|
||
itemFields,
|
||
};
|
||
}
|
||
|
||
function setStatus(message) {
|
||
el.status.textContent = message;
|
||
}
|
||
|
||
function setDirty(isDirty, message = null) {
|
||
state.dirty = isDirty;
|
||
el.dirtyState.dataset.dirty = String(isDirty);
|
||
el.dirtyState.textContent = isDirty ? "Есть несохранённые изменения" : "Синхронизировано";
|
||
|
||
if (message) {
|
||
setStatus(message);
|
||
}
|
||
}
|
||
|
||
function markDirty(message = null) {
|
||
setDirty(true, message);
|
||
}
|
||
|
||
function createMediaFilePicker() {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = MEDIA_ACCEPT;
|
||
input.className = "admin-file-picker-input";
|
||
input.tabIndex = -1;
|
||
input.setAttribute("aria-hidden", "true");
|
||
document.body.append(input);
|
||
return input;
|
||
}
|
||
|
||
function setFilePickerActive(active) {
|
||
state.filePickerActive = active;
|
||
|
||
if (active) {
|
||
document.body.dataset.filePickerActive = "true";
|
||
} else {
|
||
delete document.body.dataset.filePickerActive;
|
||
}
|
||
}
|
||
|
||
function recoverEditorSurface() {
|
||
if (!activeBlock()) return;
|
||
el.knowledgeForm.classList.add("hidden");
|
||
el.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 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) {
|
||
const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content";
|
||
state.mode = mode;
|
||
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";
|
||
document.body.dataset.adminMode = state.mode;
|
||
|
||
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-режим сайта" : "Администратор сайта";
|
||
}
|
||
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);
|
||
}
|
||
if (el.save) {
|
||
el.save.textContent = isKnowledge ? "Сохранить базу" : "Сохранить модель";
|
||
el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json";
|
||
}
|
||
if (el.render) {
|
||
el.render.textContent = isKnowledge ? "Обновить /knowledge/" : "Обновить index.html";
|
||
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
|
||
}
|
||
}
|
||
|
||
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.empty.classList.add("hidden");
|
||
el.form.classList.remove("hidden");
|
||
el.contentFields.innerHTML = "";
|
||
|
||
const note = document.createElement("div");
|
||
note.className = "empty-note editor-error-note";
|
||
note.textContent = `Редактор не смог отрисовать поля блока: ${error.message}`;
|
||
el.contentFields.append(note);
|
||
setStatus(`Ошибка редактора: ${error.message}`);
|
||
}
|
||
|
||
function renderEditor() {
|
||
updateAdminModeSurface();
|
||
el.knowledgeForm.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");
|
||
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 (state.mode === "knowledge") {
|
||
renderKnowledgeAll();
|
||
return;
|
||
}
|
||
renderRegions();
|
||
renderEditor();
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatus(`Ошибка рендера админки: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function syncBlockFromFields() {
|
||
const block = activeBlock();
|
||
if (!block) return;
|
||
|
||
syncContentFields();
|
||
block.adminLabel = el.label.value.trim() || block.adminLabel || block.id;
|
||
|
||
if (!isStaticSelection()) {
|
||
block.id = el.id.value.trim();
|
||
block.anchor = normalizeLandingTargetCandidate(el.anchor.value) || null;
|
||
el.anchor.value = block.anchor || "";
|
||
block.enabled = el.enabled.checked;
|
||
renderLandingTargetOptions();
|
||
const duplicate = landingTargetDuplicates().find((item) => item.second.block === block || item.first.block === block);
|
||
if (duplicate) {
|
||
setStatus(`Target «${duplicate.target}» уже используется в другом блоке. Переименуйте один из target-ов перед сохранением.`);
|
||
}
|
||
}
|
||
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
renderRegions();
|
||
markDirty();
|
||
}
|
||
|
||
function groupEditableFields(block) {
|
||
const groups = new Map();
|
||
|
||
for (const editableField of block.editableFields ?? []) {
|
||
const groupName = editableField.group || inferFieldGroup(editableField.path);
|
||
if (!groups.has(groupName)) groups.set(groupName, []);
|
||
groups.get(groupName).push(editableField);
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
function resetMediaPreview(preview, hint) {
|
||
preview.classList.remove("is-image", "is-video", "is-solid-dark");
|
||
preview.innerHTML = "";
|
||
preview.title = "";
|
||
if (hint) hint.textContent = "";
|
||
}
|
||
|
||
function analyzeLogoPreview(image, preview, hint, editableField) {
|
||
if (!hint || !shouldWarnAboutSolidLogoBackground(editableField)) return;
|
||
|
||
try {
|
||
const sampleSize = 24;
|
||
const canvas = document.createElement("canvas");
|
||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||
if (!context) return;
|
||
|
||
canvas.width = sampleSize;
|
||
canvas.height = sampleSize;
|
||
context.drawImage(image, 0, 0, sampleSize, sampleSize);
|
||
const data = context.getImageData(0, 0, sampleSize, sampleSize).data;
|
||
let opaque = 0;
|
||
let edgeOpaque = 0;
|
||
let edgeDark = 0;
|
||
|
||
for (let y = 0; y < sampleSize; y += 1) {
|
||
for (let x = 0; x < sampleSize; x += 1) {
|
||
const offset = (y * sampleSize + x) * 4;
|
||
const alpha = data[offset + 3];
|
||
if (alpha < 240) continue;
|
||
|
||
opaque += 1;
|
||
const isEdge = x <= 1 || y <= 1 || x >= sampleSize - 2 || y >= sampleSize - 2;
|
||
const isDark = data[offset] + data[offset + 1] + data[offset + 2] < 66;
|
||
|
||
if (isEdge) {
|
||
edgeOpaque += 1;
|
||
if (isDark) edgeDark += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
const total = sampleSize * sampleSize;
|
||
const edgeRatio = edgeOpaque ? edgeDark / edgeOpaque : 0;
|
||
const opaqueRatio = opaque / total;
|
||
|
||
if (opaqueRatio > 0.92 && edgeRatio > 0.82) {
|
||
preview.classList.add("is-solid-dark");
|
||
hint.textContent = "Файл с непрозрачным тёмным фоном. Для логотипа на тёмном интерфейсе лучше PNG/SVG с прозрачностью.";
|
||
}
|
||
} catch {
|
||
// External images can be blocked by canvas CORS. Preview still works, just without diagnostics.
|
||
}
|
||
}
|
||
|
||
function renderMediaPreview(preview, value, hint = null, editableField = null) {
|
||
resetMediaPreview(preview, hint);
|
||
|
||
const src = adminPreviewUrl(value);
|
||
const kind = mediaKindFromValue(value);
|
||
|
||
if (!src) {
|
||
preview.textContent = "FILE";
|
||
return;
|
||
}
|
||
|
||
if (kind === "video") {
|
||
const video = document.createElement("video");
|
||
preview.classList.add("is-video");
|
||
video.src = src;
|
||
video.autoplay = true;
|
||
video.loop = true;
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
preview.append(video);
|
||
return;
|
||
}
|
||
|
||
if (kind === "image") {
|
||
const image = document.createElement("img");
|
||
preview.classList.add("is-image");
|
||
image.src = src;
|
||
image.alt = "";
|
||
image.loading = "lazy";
|
||
image.addEventListener("load", () => analyzeLogoPreview(image, preview, hint, editableField));
|
||
image.addEventListener("error", () => {
|
||
resetMediaPreview(preview, hint);
|
||
preview.textContent = "FILE";
|
||
if (hint) hint.textContent = "Превью не загрузилось. Проверьте путь или внешний URL.";
|
||
});
|
||
preview.append(image);
|
||
return;
|
||
}
|
||
|
||
preview.textContent = "FILE";
|
||
}
|
||
|
||
function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, value }) {
|
||
hiddenInput.value = value;
|
||
urlInput.value = value;
|
||
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
|
||
fileName.title = fileNameFromUrl(value);
|
||
setByPath(block, editableField.path, value);
|
||
renderMediaPreview(preview, value, hint, editableField);
|
||
|
||
if (activeBlock() === block) {
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
}
|
||
|
||
markDirty();
|
||
}
|
||
|
||
async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, afterUpload, file }) {
|
||
if (!file) return;
|
||
|
||
state.mediaUploadActive = true;
|
||
setFilePickerActive(true);
|
||
recoverEditorSurface();
|
||
error.textContent = "";
|
||
fileName.textContent = "Сохраняем в assets...";
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file, file.name);
|
||
formData.append("fileName", file.name);
|
||
formData.append("mimeType", file.type || "application/octet-stream");
|
||
formData.append("bucket", uploadBucketForField(block, editableField));
|
||
|
||
const response = await fetch("/api/upload/asset", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const payload = await response.json().catch(() => ({
|
||
ok: false,
|
||
error: `Сервер вернул ${response.status}`,
|
||
}));
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "Файл не удалось сохранить");
|
||
}
|
||
|
||
updateMediaFieldValue({
|
||
block,
|
||
editableField,
|
||
hiddenInput,
|
||
urlInput,
|
||
fileName,
|
||
preview,
|
||
hint,
|
||
value: payload.url,
|
||
});
|
||
setSource("file");
|
||
markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`);
|
||
if (typeof afterUpload === "function") await afterUpload(payload);
|
||
return payload;
|
||
} catch (uploadError) {
|
||
error.textContent = uploadError.message;
|
||
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
|
||
setStatus(`Ошибка загрузки: ${uploadError.message}`);
|
||
return null;
|
||
} finally {
|
||
state.mediaUploadActive = false;
|
||
mediaFilePicker.value = "";
|
||
setFilePickerActive(false);
|
||
recoverEditorSurface();
|
||
}
|
||
}
|
||
|
||
function openMediaFilePicker(context) {
|
||
setFilePickerActive(true);
|
||
recoverEditorSurface();
|
||
window.clearTimeout(state.filePickerResetTimer);
|
||
|
||
mediaFilePicker.accept = MEDIA_ACCEPT;
|
||
mediaFilePicker.value = "";
|
||
mediaFilePicker.oncancel = () => {
|
||
setFilePickerActive(false);
|
||
recoverEditorSurface();
|
||
};
|
||
mediaFilePicker.onchange = async () => {
|
||
const file = mediaFilePicker.files?.[0];
|
||
|
||
if (!file) {
|
||
setFilePickerActive(false);
|
||
recoverEditorSurface();
|
||
return;
|
||
}
|
||
|
||
await uploadMediaFile({ ...context, file });
|
||
};
|
||
|
||
window.addEventListener(
|
||
"focus",
|
||
() => {
|
||
window.setTimeout(() => {
|
||
if (state.mediaUploadActive || mediaFilePicker.files?.length) return;
|
||
setFilePickerActive(false);
|
||
recoverEditorSurface();
|
||
}, 180);
|
||
},
|
||
{ once: true },
|
||
);
|
||
|
||
try {
|
||
mediaFilePicker.click();
|
||
} catch (error) {
|
||
setFilePickerActive(false);
|
||
recoverEditorSurface();
|
||
context.error.textContent = error.message;
|
||
setStatus(`Ошибка открытия выбора файла: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function renderMediaThumb(target, file, { large = false } = {}) {
|
||
target.innerHTML = "";
|
||
target.classList.remove("is-image", "is-video");
|
||
|
||
if (!file) {
|
||
target.textContent = "FILE";
|
||
return;
|
||
}
|
||
|
||
if (file.kind === "image") {
|
||
const image = document.createElement("img");
|
||
target.classList.add("is-image");
|
||
image.src = adminPreviewUrl(file.url);
|
||
image.alt = file.name || "";
|
||
image.loading = "lazy";
|
||
image.decoding = "async";
|
||
target.append(image);
|
||
return;
|
||
}
|
||
|
||
if (file.kind === "video") {
|
||
const video = document.createElement("video");
|
||
target.classList.add("is-video");
|
||
video.src = adminPreviewUrl(file.url);
|
||
video.muted = true;
|
||
video.loop = true;
|
||
video.playsInline = true;
|
||
video.preload = "metadata";
|
||
if (large) video.controls = true;
|
||
target.append(video);
|
||
return;
|
||
}
|
||
|
||
const label = file.kind === "model" ? "3D" : file.kind === "font" ? "FONT" : file.extension?.toUpperCase() || "FILE";
|
||
target.textContent = label;
|
||
}
|
||
|
||
function renderMediaFilterButtons() {
|
||
el.mediaKindFilters.innerHTML = "";
|
||
el.mediaRootFilters.innerHTML = "";
|
||
const activeRoot = activeMediaRootFilter();
|
||
|
||
for (const [kind, label] of MEDIA_KIND_FILTERS) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "media-filter-button";
|
||
button.dataset.active = String(state.mediaLibrary.kind === kind);
|
||
button.disabled = state.mediaLibrary.trashMode;
|
||
button.textContent = label;
|
||
button.addEventListener("click", () => {
|
||
state.mediaLibrary.kind = kind;
|
||
renderMediaLibrary();
|
||
});
|
||
el.mediaKindFilters.append(button);
|
||
}
|
||
|
||
for (const [rootPath, label] of MEDIA_ROOT_FILTERS) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "media-filter-button";
|
||
button.dataset.active = String(activeRoot === rootPath);
|
||
button.disabled = state.mediaLibrary.trashMode;
|
||
button.textContent = label;
|
||
button.title = rootPath || "Корень проекта NODEDC_SITE";
|
||
button.addEventListener("click", async () => {
|
||
state.mediaLibrary.root = rootPath;
|
||
state.mediaLibrary.selectedEntry = null;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
await loadMediaColumns(rootPath).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`));
|
||
});
|
||
el.mediaRootFilters.append(button);
|
||
}
|
||
}
|
||
|
||
function mediaPathParts(path) {
|
||
return String(path || "")
|
||
.split("/")
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function isSameOrChildPath(path, rootPath) {
|
||
if (!rootPath) return !path;
|
||
return path === rootPath || path.startsWith(`${rootPath}/`);
|
||
}
|
||
|
||
function activeMediaRootFilter() {
|
||
const selectedPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || "";
|
||
let activeRoot = "";
|
||
|
||
for (const [rootPath] of MEDIA_ROOT_FILTERS) {
|
||
if (rootPath && isSameOrChildPath(selectedPath, rootPath)) {
|
||
activeRoot = rootPath;
|
||
}
|
||
}
|
||
|
||
return activeRoot;
|
||
}
|
||
|
||
function mediaPathChain(path) {
|
||
const parts = mediaPathParts(path);
|
||
const chain = [""];
|
||
let current = "";
|
||
|
||
for (const part of parts) {
|
||
current = current ? `${current}/${part}` : part;
|
||
chain.push(current);
|
||
}
|
||
|
||
return chain;
|
||
}
|
||
|
||
function parentMediaPath(path) {
|
||
const parts = mediaPathParts(path);
|
||
parts.pop();
|
||
return parts.join("/");
|
||
}
|
||
|
||
function rememberMediaDirectory(path) {
|
||
const nextPath = String(path || "").replace(/^\/+/, "");
|
||
if (nextPath === "__trash__") return;
|
||
|
||
state.mediaLibrary.lastPath = nextPath;
|
||
updateAdminLayoutState({ lastMediaDirectory: nextPath });
|
||
}
|
||
|
||
function pathFromUrl(url) {
|
||
let value = String(url || "").split("?")[0].split("#")[0];
|
||
if (value.startsWith("./")) value = value.slice(2);
|
||
|
||
try {
|
||
value = decodeURIComponent(value);
|
||
} catch {
|
||
// Keep raw value for malformed escape sequences.
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
function mediaReplacementVariants(pathOrUrl) {
|
||
const path = pathFromUrl(pathOrUrl);
|
||
if (!path) return [];
|
||
|
||
const encodedPath = path
|
||
.split("/")
|
||
.map((part) => encodeURIComponent(part))
|
||
.join("/");
|
||
const variants = [path, `./${path}`, `/${path}`, encodedPath, `./${encodedPath}`, `/${encodedPath}`];
|
||
|
||
return [...new Set(variants)].sort((left, right) => right.length - left.length);
|
||
}
|
||
|
||
function replaceMediaReferencesInString(value, replacements) {
|
||
let nextValue = value;
|
||
|
||
for (const replacement of replacements) {
|
||
const fromVariants = mediaReplacementVariants(replacement.fromUrl || replacement.from);
|
||
const toPath = pathFromUrl(replacement.toUrl || replacement.to);
|
||
if (!fromVariants.length || !toPath) continue;
|
||
|
||
const encodedToPath = toPath
|
||
.split("/")
|
||
.map((part) => encodeURIComponent(part))
|
||
.join("/");
|
||
|
||
for (const fromVariant of fromVariants) {
|
||
const hasDotPrefix = fromVariant.startsWith("./");
|
||
const hasRootPrefix = fromVariant.startsWith("/");
|
||
const toVariant = `${hasDotPrefix ? "./" : hasRootPrefix ? "/" : ""}${fromVariant.includes("%") ? encodedToPath : toPath}`;
|
||
nextValue = nextValue.split(fromVariant).join(toVariant);
|
||
}
|
||
}
|
||
|
||
return nextValue;
|
||
}
|
||
|
||
function replaceMediaReferencesInValue(value, replacements) {
|
||
if (typeof value === "string") {
|
||
return replaceMediaReferencesInString(value, replacements);
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
let changed = false;
|
||
const nextValue = value.map((item) => {
|
||
const nextItem = replaceMediaReferencesInValue(item, replacements);
|
||
changed ||= nextItem !== item;
|
||
return nextItem;
|
||
});
|
||
return changed ? nextValue : value;
|
||
}
|
||
|
||
if (value && typeof value === "object") {
|
||
let changed = false;
|
||
const nextValue = { ...value };
|
||
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const nextItem = replaceMediaReferencesInValue(item, replacements);
|
||
if (nextItem !== item) {
|
||
nextValue[key] = nextItem;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
return changed ? nextValue : value;
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
async function browseMediaPath(path) {
|
||
const response = await fetch(`/api/media/browse?path=${encodeURIComponent(path || "")}`);
|
||
const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "Не удалось открыть папку");
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
async function refreshMediaSiteSize() {
|
||
if (!el.mediaSiteSize) return;
|
||
|
||
el.mediaSiteSize.textContent = "Сайт: считаем...";
|
||
const response = await fetch("/api/media/site-size");
|
||
const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
el.mediaSiteSize.textContent = "Сайт: --";
|
||
throw new Error(payload.error || "Не удалось посчитать размер сайта");
|
||
}
|
||
|
||
el.mediaSiteSize.textContent = `Сайт: ${payload.label}`;
|
||
el.mediaSiteSize.title = `${Number(payload.bytes || 0).toLocaleString("ru-RU")} байт`;
|
||
}
|
||
|
||
async function postMediaAction(endpoint, payload = {}) {
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const result = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
|
||
|
||
if (!response.ok || !result.ok) {
|
||
throw new Error(result.error || "Операция не выполнена");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
function mediaDragHasEntry(event) {
|
||
return Array.from(event.dataTransfer?.types || []).includes(MEDIA_DRAG_MIME);
|
||
}
|
||
|
||
function mediaDragPayload(event) {
|
||
const rawPayload = event.dataTransfer?.getData(MEDIA_DRAG_MIME);
|
||
if (!rawPayload) return null;
|
||
|
||
try {
|
||
return JSON.parse(rawPayload);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function syncMediaFieldValueFromServer(context, value) {
|
||
if (!context || !value) return;
|
||
|
||
const { block, editableField, hiddenInput, urlInput, fileName, preview, hint } = context;
|
||
hiddenInput.value = value;
|
||
urlInput.value = value;
|
||
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
|
||
fileName.title = fileNameFromUrl(value);
|
||
setByPath(block, editableField.path, value);
|
||
renderMediaPreview(preview, value, hint, editableField);
|
||
|
||
if (activeBlock() === block) {
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
}
|
||
|
||
context.setSource("file");
|
||
}
|
||
|
||
function applyMediaReferenceReplacements(replacements = []) {
|
||
const context = state.mediaLibrary.context;
|
||
if (!Array.isArray(replacements) || !replacements.length) return false;
|
||
let changed = false;
|
||
const contextPath = context?.hiddenInput ? pathFromUrl(context.hiddenInput.value || "") : "";
|
||
|
||
const nextPage = replaceMediaReferencesInValue(state.page, replacements);
|
||
if (nextPage !== state.page) {
|
||
state.page = nextPage;
|
||
changed = true;
|
||
}
|
||
|
||
document.querySelectorAll("input.service-media-hidden-input").forEach((input) => {
|
||
const nextValue = replaceMediaReferencesInString(input.value || "", replacements);
|
||
if (nextValue === input.value) return;
|
||
|
||
input.value = nextValue;
|
||
const field = input.closest(".service-media-field");
|
||
const fileName = field?.querySelector(".service-media-file-name");
|
||
const urlInput = field?.querySelector(".service-media-url-input");
|
||
if (urlInput) urlInput.value = nextValue;
|
||
if (fileName) {
|
||
fileName.textContent = truncateText(fileNameFromUrl(nextValue), 28);
|
||
fileName.title = fileNameFromUrl(nextValue);
|
||
}
|
||
changed = true;
|
||
});
|
||
|
||
if (activeBlock()) {
|
||
el.json.value = JSON.stringify(activeBlock(), null, 2);
|
||
}
|
||
|
||
if (!context?.hiddenInput) return changed;
|
||
|
||
const match = replacements.find((replacement) => pathFromUrl(replacement.fromUrl || replacement.from || "") === contextPath);
|
||
if (!match?.toUrl) return changed;
|
||
|
||
syncMediaFieldValueFromServer(context, match.toUrl);
|
||
return true;
|
||
}
|
||
|
||
function canDropMediaEntryOnTarget(entry, targetPath) {
|
||
if (!entry || state.mediaLibrary.trashMode) return false;
|
||
if (!entry.path) return false;
|
||
|
||
const normalizedTarget = String(targetPath || "").replace(/^\/+/, "");
|
||
const sourceParent = parentMediaPath(entry.path);
|
||
|
||
if (entry.type === "directory" && (normalizedTarget === entry.path || normalizedTarget.startsWith(`${entry.path}/`))) {
|
||
return false;
|
||
}
|
||
|
||
return normalizedTarget !== sourceParent;
|
||
}
|
||
|
||
function setMediaDropActive(node, active) {
|
||
if (!node) return;
|
||
if (active) {
|
||
node.dataset.dropActive = "true";
|
||
return;
|
||
}
|
||
|
||
delete node.dataset.dropActive;
|
||
}
|
||
|
||
function handleMediaDragOver(event, targetPath) {
|
||
if (!mediaDragHasEntry(event)) return;
|
||
const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath);
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.dataTransfer.dropEffect = canDropMediaEntryOnTarget(entry, targetPath) ? "move" : "none";
|
||
setMediaDropActive(event.currentTarget, event.dataTransfer.dropEffect === "move");
|
||
}
|
||
|
||
function handleMediaDragLeave(event) {
|
||
if (event.currentTarget.contains(event.relatedTarget)) return;
|
||
setMediaDropActive(event.currentTarget, false);
|
||
}
|
||
|
||
async function moveMediaEntryToPath(entry, targetPath) {
|
||
if (!canDropMediaEntryOnTarget(entry, targetPath)) return;
|
||
|
||
const payload = await postMediaAction("/api/media/move", { path: entry.path, targetPath });
|
||
applyMediaReferenceReplacements(payload.replacements);
|
||
|
||
const nextParentPath = parentMediaPath(payload.path);
|
||
await loadMediaColumns(nextParentPath, { selectedUrl: payload.url || "" });
|
||
|
||
const movedEntry = findEntryByPath(payload.path);
|
||
if (movedEntry) {
|
||
state.mediaLibrary.selectedPath = nextParentPath;
|
||
state.mediaLibrary.selectedEntry = movedEntry;
|
||
state.mediaLibrary.selectedUrl = movedEntry.type === "file" ? movedEntry.url || "" : "";
|
||
renderMediaLibrary();
|
||
}
|
||
|
||
const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0;
|
||
setStatus(`Перенесено: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`);
|
||
}
|
||
|
||
function handleMediaDrop(event, targetPath) {
|
||
if (!mediaDragHasEntry(event)) return;
|
||
const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath);
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setMediaDropActive(event.currentTarget, false);
|
||
if (!canDropMediaEntryOnTarget(entry, targetPath)) return;
|
||
|
||
moveMediaEntryToPath(entry, targetPath).catch((error) => setStatus(`Ошибка переноса: ${error.message}`));
|
||
}
|
||
|
||
async function browseMediaTrash() {
|
||
const response = await fetch("/api/media/trash");
|
||
const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` }));
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "Не удалось открыть удалённые");
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
path: "__trash__",
|
||
breadcrumbs: [{ name: "Удалённые", path: "__trash__" }],
|
||
entries: payload.files || [],
|
||
};
|
||
}
|
||
|
||
function entryMatchesMediaFilters(entry) {
|
||
const query = state.mediaLibrary.query.trim().toLowerCase();
|
||
|
||
if (query) {
|
||
const matchesQuery = [entry.name, entry.path, entry.url, mediaUsageLabel(entry)]
|
||
.filter(Boolean)
|
||
.some((value) => String(value).toLowerCase().includes(query));
|
||
if (!matchesQuery) return false;
|
||
}
|
||
|
||
if (entry.type === "directory") return true;
|
||
if (state.mediaLibrary.kind === "all") return true;
|
||
return entry.kind === state.mediaLibrary.kind;
|
||
}
|
||
|
||
function findEntryByUrl(url) {
|
||
for (const column of state.mediaLibrary.columns) {
|
||
const match = column.entries.find((entry) => entry.url === url);
|
||
if (match) return match;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function findEntryByPath(path) {
|
||
if (!path) return null;
|
||
|
||
for (const column of state.mediaLibrary.columns) {
|
||
const match = column.entries.find((entry) => entry.path === path);
|
||
if (match) return match;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function canDeleteMediaEntry(entry) {
|
||
if (!entry || state.mediaLibrary.trashMode) return false;
|
||
if (entry.type === "directory") return entry.path?.startsWith("assets/") && !entry.path.split("/").includes(".trash");
|
||
|
||
const isCurrentFieldValue = state.mediaLibrary.context?.hiddenInput?.value === entry.url;
|
||
return entry.type === "file" && entry.writable && mediaUsageStatus(entry) === "unused" && !isCurrentFieldValue;
|
||
}
|
||
|
||
function selectMediaEntry(entry, columnPath = state.mediaLibrary.selectedPath) {
|
||
if (!entry) return;
|
||
|
||
if (entry.type === "file") {
|
||
state.mediaLibrary.selectedPath = columnPath;
|
||
state.mediaLibrary.selectedUrl = entry.url || "";
|
||
} else {
|
||
state.mediaLibrary.selectedUrl = "";
|
||
}
|
||
|
||
state.mediaLibrary.selectedEntry = entry;
|
||
renderMediaLibrary();
|
||
}
|
||
|
||
function closeMediaContextMenu() {
|
||
el.mediaContextMenu.classList.add("hidden");
|
||
el.mediaContextMenu.innerHTML = "";
|
||
state.mediaLibrary.contextMenu = null;
|
||
}
|
||
|
||
function appendMediaContextAction(label, action, { disabled = false } = {}) {
|
||
const button = document.createElement("button");
|
||
|
||
button.type = "button";
|
||
button.setAttribute("role", "menuitem");
|
||
button.textContent = label;
|
||
button.disabled = disabled;
|
||
button.addEventListener("click", () => {
|
||
closeMediaContextMenu();
|
||
action();
|
||
});
|
||
el.mediaContextMenu.append(button);
|
||
}
|
||
|
||
function openMediaContextMenu(event, { entry = null, columnPath = state.mediaLibrary.selectedPath } = {}) {
|
||
event.preventDefault();
|
||
closeMediaContextMenu();
|
||
state.mediaLibrary.contextMenu = { entry, columnPath };
|
||
|
||
if (entry) {
|
||
selectMediaEntry(entry, columnPath);
|
||
}
|
||
|
||
if (state.mediaLibrary.trashMode) {
|
||
if (entry?.type === "file") {
|
||
appendMediaContextAction("Восстановить", () => restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`)));
|
||
appendMediaContextAction("Удалить навсегда", () => deleteSelectedTrashFile().catch((error) => setStatus(`Ошибка удаления из корзины: ${error.message}`)));
|
||
}
|
||
} else if (entry?.type === "file" || entry?.type === "directory") {
|
||
appendMediaContextAction("Дублировать", () => duplicateMediaEntry(entry).catch((error) => setStatus(`Ошибка дублирования: ${error.message}`)));
|
||
appendMediaContextAction("Переименовать", () => renameMediaEntry(entry).catch((error) => setStatus(`Ошибка переименования: ${error.message}`)));
|
||
appendMediaContextAction("Удалить", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)), {
|
||
disabled: !canDeleteMediaEntry(entry),
|
||
});
|
||
} else {
|
||
appendMediaContextAction("Создать папку", () => createMediaFolderAt(columnPath).catch((error) => setStatus(`Ошибка создания папки: ${error.message}`)));
|
||
}
|
||
|
||
if (!el.mediaContextMenu.children.length) return;
|
||
|
||
el.mediaContextMenu.classList.remove("hidden");
|
||
const rect = el.mediaContextMenu.getBoundingClientRect();
|
||
const x = Math.min(event.clientX, window.innerWidth - rect.width - 12);
|
||
const y = Math.min(event.clientY, window.innerHeight - rect.height - 12);
|
||
el.mediaContextMenu.style.left = `${Math.max(12, x)}px`;
|
||
el.mediaContextMenu.style.top = `${Math.max(12, y)}px`;
|
||
}
|
||
|
||
function scrollMediaBrowserToSelection() {
|
||
window.requestAnimationFrame(() => {
|
||
const columns = [...el.mediaList.querySelectorAll(".media-column")];
|
||
const targetColumn = columns.at(-1);
|
||
const exactEntry = el.mediaList.querySelector('.media-entry[data-exact-active="true"]');
|
||
const activeEntry = exactEntry || el.mediaList.querySelector('.media-entry[data-active="true"]');
|
||
|
||
if (targetColumn) {
|
||
const padding = 14;
|
||
const maxScrollLeft = Math.max(0, el.mediaList.scrollWidth - el.mediaList.clientWidth);
|
||
const targetRight = targetColumn.offsetLeft + targetColumn.offsetWidth + padding;
|
||
const targetLeft = targetColumn.offsetLeft - padding;
|
||
let nextScrollLeft = el.mediaList.scrollLeft;
|
||
|
||
if (targetRight > nextScrollLeft + el.mediaList.clientWidth) {
|
||
nextScrollLeft = targetRight - el.mediaList.clientWidth;
|
||
}
|
||
|
||
if (targetLeft < nextScrollLeft) {
|
||
nextScrollLeft = targetLeft;
|
||
}
|
||
|
||
el.mediaList.scrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft);
|
||
}
|
||
|
||
if (activeEntry) {
|
||
const list = activeEntry.closest(".media-column-list");
|
||
if (!list) return;
|
||
|
||
const padding = 8;
|
||
const nextSlotHeight = activeEntry.offsetHeight + 4;
|
||
const targetBottom = activeEntry.offsetTop + activeEntry.offsetHeight + nextSlotHeight + padding;
|
||
const targetTop = activeEntry.offsetTop - padding;
|
||
const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight);
|
||
let nextScrollTop = list.scrollTop;
|
||
|
||
if (targetBottom > nextScrollTop + list.clientHeight) {
|
||
nextScrollTop = targetBottom - list.clientHeight;
|
||
}
|
||
|
||
if (targetTop < nextScrollTop) {
|
||
nextScrollTop = targetTop;
|
||
}
|
||
|
||
list.scrollTop = Math.min(Math.max(nextScrollTop, 0), maxScrollTop);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadMediaColumns(targetPath, { selectedUrl = state.mediaLibrary.selectedUrl } = {}) {
|
||
const path = String(targetPath || "").replace(/^\/+/, "");
|
||
const chain = mediaPathChain(path);
|
||
const columns = [];
|
||
|
||
state.mediaLibrary.trashMode = false;
|
||
setStatus("Открываем файловую структуру...");
|
||
|
||
for (const columnPath of chain) {
|
||
columns.push(await browseMediaPath(columnPath));
|
||
}
|
||
|
||
state.mediaLibrary.columns = columns;
|
||
state.mediaLibrary.selectedPath = path;
|
||
state.mediaLibrary.loaded = true;
|
||
state.mediaLibrary.selectedUrl = selectedUrl || "";
|
||
state.mediaLibrary.selectedEntry = selectedUrl ? findEntryByUrl(selectedUrl) : findEntryByPath(path);
|
||
rememberMediaDirectory(path);
|
||
renderMediaLibrary();
|
||
setStatus(`Файловая структура: ${path || "корень проекта"}.`);
|
||
}
|
||
|
||
async function loadMediaTrash({ selectedTrashPath = "" } = {}) {
|
||
setStatus("Открываем удалённые файлы...");
|
||
const column = await browseMediaTrash();
|
||
|
||
state.mediaLibrary.trashMode = true;
|
||
state.mediaLibrary.columns = [column];
|
||
state.mediaLibrary.selectedPath = "__trash__";
|
||
state.mediaLibrary.selectedUrl = "";
|
||
state.mediaLibrary.loaded = true;
|
||
state.mediaLibrary.selectedEntry = selectedTrashPath ? findEntryByPath(selectedTrashPath) : column.entries[0] || null;
|
||
renderMediaLibrary();
|
||
setStatus(`Удалённые файлы: ${column.entries.length}.`);
|
||
}
|
||
|
||
function renderMediaBreadcrumbs() {
|
||
el.mediaBreadcrumbs.innerHTML = "";
|
||
const breadcrumbs = state.mediaLibrary.columns.at(-1)?.breadcrumbs || [{ name: "NODEDC_SITE", path: "" }];
|
||
|
||
breadcrumbs.forEach((crumb, index) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "media-breadcrumb-button";
|
||
button.textContent = crumb.name;
|
||
button.dataset.active = String(index === breadcrumbs.length - 1);
|
||
button.addEventListener("click", () => {
|
||
state.mediaLibrary.root = crumb.path;
|
||
state.mediaLibrary.selectedEntry = null;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
loadMediaColumns(crumb.path).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`));
|
||
});
|
||
el.mediaBreadcrumbs.append(button);
|
||
});
|
||
}
|
||
|
||
function renderMediaEntry(entry, columnPath) {
|
||
const button = document.createElement("button");
|
||
const icon = document.createElement("span");
|
||
const copy = document.createElement("span");
|
||
const name = document.createElement("span");
|
||
const meta = document.createElement("span");
|
||
const statusDot = document.createElement("span");
|
||
const arrow = document.createElement("span");
|
||
const isExactSelected = state.mediaLibrary.selectedEntry?.path === entry.path;
|
||
const isSelected =
|
||
isExactSelected ||
|
||
(entry.type === "directory" && isSameOrChildPath(state.mediaLibrary.selectedPath, entry.path));
|
||
|
||
button.type = "button";
|
||
button.className = "media-entry";
|
||
button.dataset.type = entry.type;
|
||
button.dataset.path = entry.path;
|
||
button.dataset.active = String(isSelected);
|
||
button.dataset.exactActive = String(isExactSelected);
|
||
button.title = entry.url || entry.path;
|
||
button.draggable = !state.mediaLibrary.trashMode;
|
||
|
||
icon.className = "media-entry-icon";
|
||
if (entry.type === "file" && entry.url && entry.kind === "image") {
|
||
const image = document.createElement("img");
|
||
icon.classList.add("is-preview");
|
||
image.src = adminPreviewUrl(entry.url);
|
||
image.alt = "";
|
||
image.loading = "lazy";
|
||
image.decoding = "async";
|
||
icon.append(image);
|
||
} else if (entry.type === "file" && entry.url && entry.kind === "video") {
|
||
const video = document.createElement("video");
|
||
icon.classList.add("is-preview");
|
||
video.src = adminPreviewUrl(entry.url);
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.preload = "metadata";
|
||
icon.append(video);
|
||
} else {
|
||
icon.textContent =
|
||
entry.type === "directory"
|
||
? "DIR"
|
||
: entry.kind === "model"
|
||
? "3D"
|
||
: entry.kind === "font"
|
||
? "Aa"
|
||
: entry.extension?.toUpperCase() || "FILE";
|
||
}
|
||
|
||
copy.className = "media-entry-copy";
|
||
name.className = "media-entry-name";
|
||
name.textContent = entry.name;
|
||
meta.className = "media-entry-meta";
|
||
meta.textContent =
|
||
entry.type === "directory"
|
||
? entry.path || "Корень проекта"
|
||
: [mediaUsageLabel(entry), entry.extension, formatFileSize(entry.size)].filter(Boolean).join(" · ");
|
||
copy.append(name, meta);
|
||
|
||
statusDot.className = "media-status-dot media-entry-status";
|
||
statusDot.dataset.status = mediaUsageStatus(entry);
|
||
statusDot.title = mediaUsageLabel(entry);
|
||
|
||
arrow.className = "media-entry-arrow";
|
||
arrow.textContent = entry.type === "directory" ? "›" : "";
|
||
|
||
button.append(icon, copy);
|
||
if (entry.type === "file") button.append(statusDot);
|
||
button.append(arrow);
|
||
|
||
button.addEventListener("click", async () => {
|
||
if (entry.type === "directory") {
|
||
state.mediaLibrary.root = entry.path;
|
||
state.mediaLibrary.selectedEntry = entry;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
await loadMediaColumns(entry.path);
|
||
return;
|
||
}
|
||
|
||
selectMediaEntry(entry, columnPath);
|
||
});
|
||
button.addEventListener("contextmenu", (event) => {
|
||
event.stopPropagation();
|
||
openMediaContextMenu(event, { entry, columnPath });
|
||
});
|
||
button.addEventListener("dblclick", () => {
|
||
if (entry.type === "file" && entry.selectable) chooseSelectedMediaFile();
|
||
});
|
||
button.addEventListener("dragstart", (event) => {
|
||
if (state.mediaLibrary.trashMode) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
|
||
const payload = JSON.stringify({ path: entry.path, type: entry.type });
|
||
state.mediaLibrary.dragEntryPath = entry.path;
|
||
button.dataset.dragging = "true";
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData(MEDIA_DRAG_MIME, payload);
|
||
event.dataTransfer.setData("text/plain", entry.path);
|
||
});
|
||
button.addEventListener("dragend", () => {
|
||
state.mediaLibrary.dragEntryPath = "";
|
||
delete button.dataset.dragging;
|
||
el.mediaList.querySelectorAll("[data-drop-active]").forEach((node) => setMediaDropActive(node, false));
|
||
});
|
||
|
||
if (entry.type === "directory") {
|
||
button.addEventListener("dragover", (event) => handleMediaDragOver(event, entry.path));
|
||
button.addEventListener("dragleave", handleMediaDragLeave);
|
||
button.addEventListener("drop", (event) => handleMediaDrop(event, entry.path));
|
||
}
|
||
|
||
return button;
|
||
}
|
||
|
||
function renderMediaColumn(column) {
|
||
const columnNode = document.createElement("section");
|
||
const head = document.createElement("div");
|
||
const title = document.createElement("div");
|
||
const count = document.createElement("div");
|
||
const list = document.createElement("div");
|
||
const entries = (column.entries || []).filter(entryMatchesMediaFilters);
|
||
|
||
columnNode.className = "media-column";
|
||
head.className = "media-column-head";
|
||
title.className = "media-column-title";
|
||
title.textContent = column.path ? mediaPathParts(column.path).at(-1) : "NODEDC_SITE";
|
||
count.className = "media-column-count";
|
||
count.textContent = `${entries.length}`;
|
||
head.append(title, count);
|
||
|
||
list.className = "media-column-list";
|
||
list.dataset.path = column.path || "";
|
||
list.addEventListener("dragover", (event) => handleMediaDragOver(event, column.path || ""));
|
||
list.addEventListener("dragleave", handleMediaDragLeave);
|
||
list.addEventListener("drop", (event) => handleMediaDrop(event, column.path || ""));
|
||
list.addEventListener("contextmenu", (event) => {
|
||
if (event.target.closest(".media-entry")) return;
|
||
openMediaContextMenu(event, { columnPath: column.path || "" });
|
||
});
|
||
for (const entry of entries) {
|
||
list.append(renderMediaEntry(entry, column.path));
|
||
}
|
||
|
||
if (!entries.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "media-column-empty";
|
||
empty.textContent = "Пусто";
|
||
list.append(empty);
|
||
}
|
||
|
||
columnNode.append(head, list);
|
||
return columnNode;
|
||
}
|
||
|
||
function mediaColumnByPath(path) {
|
||
return state.mediaLibrary.columns.find((column) => column.path === path) || null;
|
||
}
|
||
|
||
function adjacentMediaFileUrl(file) {
|
||
const selectedPath = state.mediaLibrary.selectedPath || file?.directory || parentMediaPath(pathFromUrl(file?.url || ""));
|
||
const column = mediaColumnByPath(selectedPath);
|
||
if (!column) return "";
|
||
|
||
const visibleFiles = column.entries.filter(entryMatchesMediaFilters).filter((entry) => entry.type === "file");
|
||
let index = visibleFiles.findIndex((entry) => entry.path === file.path);
|
||
let adjacent = index >= 0 ? visibleFiles[index + 1] || visibleFiles[index - 1] : null;
|
||
|
||
if (!adjacent) {
|
||
const files = column.entries.filter((entry) => entry.type === "file");
|
||
index = files.findIndex((entry) => entry.path === file.path);
|
||
adjacent = index >= 0 ? files[index + 1] || files[index - 1] : null;
|
||
}
|
||
|
||
return adjacent?.url || "";
|
||
}
|
||
|
||
function adjacentMediaEntryPath(entry) {
|
||
if (!entry?.path) return "";
|
||
|
||
const column = mediaColumnForEntry(entry) || mediaColumnByPath(parentMediaPath(entry.path));
|
||
if (!column) return "";
|
||
|
||
const entries = visibleMediaEntries(column);
|
||
let index = entries.findIndex((candidate) => candidate.path === entry.path);
|
||
let adjacent = index >= 0 ? entries[index + 1] || entries[index - 1] : null;
|
||
|
||
if (!adjacent) {
|
||
index = column.entries.findIndex((candidate) => candidate.path === entry.path);
|
||
adjacent = index >= 0 ? column.entries[index + 1] || column.entries[index - 1] : null;
|
||
}
|
||
|
||
return adjacent?.path || "";
|
||
}
|
||
|
||
function visibleMediaEntries(column) {
|
||
return (column?.entries || []).filter(entryMatchesMediaFilters);
|
||
}
|
||
|
||
function mediaColumnForEntry(entry) {
|
||
if (!entry) return null;
|
||
return state.mediaLibrary.columns.find((column) => column.entries.some((candidate) => candidate.path === entry.path)) || null;
|
||
}
|
||
|
||
function activeMediaColumn() {
|
||
return (
|
||
mediaColumnForEntry(state.mediaLibrary.selectedEntry) ||
|
||
state.mediaLibrary.columns.find((column) => column.path === state.mediaLibrary.selectedPath) ||
|
||
state.mediaLibrary.columns.at(-1) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
async function moveMediaSelection(delta) {
|
||
const column = activeMediaColumn();
|
||
const entries = visibleMediaEntries(column);
|
||
if (!column || !entries.length) return;
|
||
|
||
const currentIndex = entries.findIndex((entry) => entry.path === state.mediaLibrary.selectedEntry?.path);
|
||
const fallbackIndex = delta > 0 ? 0 : entries.length - 1;
|
||
const nextIndex = currentIndex < 0 ? fallbackIndex : Math.min(Math.max(currentIndex + delta, 0), entries.length - 1);
|
||
const nextEntry = entries[nextIndex];
|
||
|
||
if (nextEntry.type === "directory" && !state.mediaLibrary.trashMode) {
|
||
state.mediaLibrary.root = nextEntry.path;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
await loadMediaColumns(nextEntry.path, { selectedUrl: "" });
|
||
|
||
const selectedDirectory = findEntryByPath(nextEntry.path);
|
||
if (selectedDirectory) {
|
||
state.mediaLibrary.selectedPath = column.path;
|
||
state.mediaLibrary.selectedEntry = selectedDirectory;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
renderMediaLibrary();
|
||
}
|
||
return;
|
||
}
|
||
|
||
selectMediaEntry(nextEntry, column.path);
|
||
}
|
||
|
||
async function openSelectedMediaDirectory() {
|
||
const entry = state.mediaLibrary.selectedEntry;
|
||
if (!entry || entry.type !== "directory" || state.mediaLibrary.trashMode) return;
|
||
|
||
state.mediaLibrary.root = entry.path;
|
||
state.mediaLibrary.selectedEntry = entry;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
await loadMediaColumns(entry.path, { selectedUrl: "" });
|
||
|
||
const openedColumn = mediaColumnByPath(entry.path);
|
||
const firstEntry = visibleMediaEntries(openedColumn)[0];
|
||
if (firstEntry) {
|
||
state.mediaLibrary.selectedPath = entry.path;
|
||
state.mediaLibrary.selectedEntry = firstEntry;
|
||
state.mediaLibrary.selectedUrl = firstEntry.type === "file" ? firstEntry.url || "" : "";
|
||
renderMediaLibrary();
|
||
}
|
||
}
|
||
|
||
async function moveMediaHierarchyBack() {
|
||
if (state.mediaLibrary.trashMode) return;
|
||
const path = state.mediaLibrary.selectedPath || state.mediaLibrary.selectedEntry?.path || "";
|
||
if (!path) return;
|
||
|
||
const parentPath = parentMediaPath(path);
|
||
state.mediaLibrary.selectedUrl = "";
|
||
await loadMediaColumns(parentPath, { selectedUrl: "" });
|
||
|
||
const closedDirectory = findEntryByPath(path);
|
||
if (closedDirectory) {
|
||
state.mediaLibrary.selectedPath = parentPath;
|
||
state.mediaLibrary.selectedEntry = closedDirectory;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
renderMediaLibrary();
|
||
}
|
||
}
|
||
|
||
function isTypingTarget(target) {
|
||
return Boolean(target?.closest?.("input, textarea, select, [contenteditable='true']"));
|
||
}
|
||
|
||
function renderMediaPreviewPanel() {
|
||
const file = selectedMediaFile();
|
||
const entry = state.mediaLibrary.selectedEntry;
|
||
const usageText = el.mediaPreviewUsage.lastElementChild;
|
||
|
||
renderMediaThumb(el.mediaPreview, file, { large: true });
|
||
el.mediaSelect.textContent = state.mediaLibrary.trashMode ? "Восстановить" : "Выбрать";
|
||
el.mediaSelect.disabled = !file;
|
||
el.mediaDelete.disabled = true;
|
||
|
||
if (!entry) {
|
||
el.mediaPreviewName.textContent = "Выберите файл или папку";
|
||
el.mediaPreviewPath.textContent = state.mediaLibrary.selectedPath || "NODEDC_SITE";
|
||
el.mediaPreviewMeta.textContent = "Column view проекта";
|
||
el.mediaPreviewUsage.dataset.status = "idle";
|
||
if (usageText) usageText.textContent = "Нет выбора";
|
||
return;
|
||
}
|
||
|
||
if (entry.type === "directory") {
|
||
const canDeleteDirectory = canDeleteMediaEntry(entry);
|
||
el.mediaPreview.innerHTML = "<span>DIR</span>";
|
||
el.mediaPreviewName.textContent = entry.name;
|
||
el.mediaPreviewPath.textContent = entry.path || "NODEDC_SITE";
|
||
el.mediaPreviewPath.title = entry.path || "NODEDC_SITE";
|
||
el.mediaPreviewMeta.textContent = "Папка проекта";
|
||
el.mediaPreviewUsage.dataset.status = "idle";
|
||
if (usageText) usageText.textContent = "Папка";
|
||
el.mediaDelete.disabled = !canDeleteDirectory;
|
||
el.mediaDelete.title = canDeleteDirectory ? "Перенести папку в assets/.trash" : "Удаление доступно только для папок внутри assets";
|
||
return;
|
||
}
|
||
|
||
if (state.mediaLibrary.trashMode) {
|
||
el.mediaSelect.disabled = !entry.restorable;
|
||
el.mediaPreviewName.textContent = entry.originalName || entry.name;
|
||
el.mediaPreviewPath.textContent = entry.originalPath || entry.path;
|
||
el.mediaPreviewPath.title = entry.originalPath || entry.path;
|
||
el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · ");
|
||
el.mediaPreviewUsage.dataset.status = "trash";
|
||
if (usageText) usageText.textContent = "Удалён";
|
||
el.mediaDelete.disabled = !entry.trashPath;
|
||
el.mediaDelete.title = "Удалить выбранный файл из корзины навсегда";
|
||
return;
|
||
}
|
||
|
||
const canDelete = canDeleteMediaEntry(entry);
|
||
|
||
el.mediaSelect.disabled = !entry.selectable;
|
||
el.mediaPreviewName.textContent = entry.name;
|
||
el.mediaPreviewPath.textContent = entry.url || entry.path;
|
||
el.mediaPreviewPath.title = entry.url || entry.path;
|
||
el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · ");
|
||
el.mediaPreviewUsage.dataset.status = mediaUsageStatus(entry);
|
||
if (usageText) usageText.textContent = entry.selectable ? mediaUsageLabel(entry) : "Не публичный asset";
|
||
el.mediaDelete.disabled = !canDelete;
|
||
el.mediaDelete.title = canDelete
|
||
? "Перенести unused-файл в assets/.trash"
|
||
: "Удаление доступно только для unused-файлов из assets";
|
||
}
|
||
|
||
function renderMediaLibrary() {
|
||
el.mediaTrashToggle.dataset.active = String(state.mediaLibrary.trashMode);
|
||
el.mediaUpload.textContent = state.mediaLibrary.trashMode ? "Очистить корзину" : "+ Загрузить";
|
||
renderMediaFilterButtons();
|
||
renderMediaBreadcrumbs();
|
||
el.mediaList.innerHTML = "";
|
||
el.mediaList.classList.add("media-columns");
|
||
|
||
const columns = state.mediaLibrary.columns || [];
|
||
el.mediaEmpty.classList.toggle("hidden", columns.length > 0);
|
||
for (const column of columns) {
|
||
el.mediaList.append(renderMediaColumn(column));
|
||
}
|
||
|
||
renderMediaPreviewPanel();
|
||
scrollMediaBrowserToSelection();
|
||
}
|
||
|
||
async function refreshMediaLibrary() {
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
|
||
if (state.mediaLibrary.trashMode) {
|
||
await loadMediaTrash({ selectedTrashPath: state.mediaLibrary.selectedEntry?.trashPath || "" });
|
||
return;
|
||
}
|
||
|
||
await loadMediaColumns(state.mediaLibrary.selectedPath || state.mediaLibrary.root || "");
|
||
}
|
||
|
||
async function toggleMediaTrash() {
|
||
if (state.mediaLibrary.trashMode) {
|
||
await loadMediaColumns(state.mediaLibrary.previousPath || state.mediaLibrary.root || "");
|
||
return;
|
||
}
|
||
|
||
state.mediaLibrary.previousPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || "";
|
||
state.mediaLibrary.selectedEntry = null;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
await loadMediaTrash();
|
||
}
|
||
|
||
async function openMediaLibrary(context) {
|
||
const currentValue = context.hiddenInput?.value || "";
|
||
const currentValuePath = pathFromUrl(currentValue);
|
||
const suggestedPath = `assets/uploads/${uploadBucketForField(context.block, context.editableField)}`;
|
||
const lastPath = state.mediaLibrary.lastPath || readLastMediaDirectory();
|
||
const startPath = lastPath || (currentValuePath ? parentMediaPath(currentValuePath) : suggestedPath);
|
||
|
||
state.mediaLibrary.context = context;
|
||
state.mediaLibrary.trashMode = false;
|
||
state.mediaLibrary.previousPath = "";
|
||
state.mediaLibrary.selectedUrl = currentValue;
|
||
state.mediaLibrary.selectedEntry = null;
|
||
state.mediaLibrary.selectedPath = startPath;
|
||
state.mediaLibrary.query = "";
|
||
state.mediaLibrary.root = startPath;
|
||
state.mediaLibrary.kind = "all";
|
||
el.mediaSearch.value = "";
|
||
el.mediaModalTitle.textContent = context.editableField.label || "Выбрать файл";
|
||
el.mediaModalSubtitle.textContent = `${context.editableField.path} -> ${suggestedPath}`;
|
||
el.mediaModal.classList.remove("hidden");
|
||
el.mediaSearch.focus();
|
||
refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
|
||
try {
|
||
await loadMediaColumns(startPath, { selectedUrl: currentValue });
|
||
} catch (error) {
|
||
setStatus(`Ошибка медиатеки: ${error.message}`);
|
||
renderMediaLibrary();
|
||
}
|
||
}
|
||
|
||
function closeMediaLibrary() {
|
||
closeMediaContextMenu();
|
||
el.mediaModal.classList.add("hidden");
|
||
delete el.mediaDropZone.dataset.dragActive;
|
||
state.mediaLibrary.context = null;
|
||
state.mediaLibrary.trashMode = false;
|
||
state.mediaLibrary.selectedUrl = "";
|
||
state.mediaLibrary.selectedEntry = null;
|
||
recoverEditorSurface();
|
||
}
|
||
|
||
function chooseSelectedMediaFile() {
|
||
const file = selectedMediaFile();
|
||
const context = state.mediaLibrary.context;
|
||
if (!file || !context) return;
|
||
|
||
if (state.mediaLibrary.trashMode) {
|
||
restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`));
|
||
return;
|
||
}
|
||
|
||
if (typeof context.onSelect === "function") {
|
||
context.onSelect(file.url, file);
|
||
} else {
|
||
updateMediaFieldValue({
|
||
...context,
|
||
value: file.url,
|
||
});
|
||
context.setSource("file");
|
||
}
|
||
rememberMediaDirectory(file.directory || parentMediaPath(pathFromUrl(file.url)));
|
||
closeMediaLibrary();
|
||
markDirty(`Выбран файл: ${file.url}. Нажмите «Сохранить модель», чтобы записать путь.`);
|
||
}
|
||
|
||
async function restoreSelectedMediaFile() {
|
||
const file = selectedMediaFile();
|
||
if (!file?.trashPath) return;
|
||
|
||
const payload = await postMediaAction("/api/media/restore", { trashPath: file.trashPath });
|
||
await loadMediaTrash();
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
setStatus(`Файл восстановлен: ${payload.path}.`);
|
||
}
|
||
|
||
async function clearMediaTrash() {
|
||
const confirmed = await requestDeleteConfirmation({
|
||
title: "Очистить корзину?",
|
||
body: "Все файлы из удалённых будут удалены окончательно.",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
await postMediaAction("/api/media/trash/clear");
|
||
await loadMediaTrash();
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
setStatus("Корзина очищена.");
|
||
}
|
||
|
||
async function createMediaFolderAt(path) {
|
||
if (state.mediaLibrary.trashMode) return;
|
||
|
||
const folderName = await requestMediaActionInput({
|
||
title: "Создать папку",
|
||
body: `Новая папка будет создана в ${path || "корне проекта"}.`,
|
||
label: "Название папки",
|
||
value: "new-folder",
|
||
confirmLabel: "Создать",
|
||
});
|
||
|
||
if (!folderName) return;
|
||
|
||
const payload = await postMediaAction("/api/media/folder", { path, name: folderName });
|
||
await loadMediaColumns(path);
|
||
state.mediaLibrary.selectedEntry = findEntryByPath(payload.path);
|
||
state.mediaLibrary.selectedUrl = "";
|
||
renderMediaLibrary();
|
||
setStatus(`Папка создана: ${payload.path}.`);
|
||
}
|
||
|
||
async function renameMediaEntry(entry) {
|
||
if (!entry || state.mediaLibrary.trashMode) return;
|
||
|
||
const nextName = await requestMediaActionInput({
|
||
title: "Переименовать",
|
||
body: entry.path,
|
||
label: "Новое имя",
|
||
value: entry.name,
|
||
confirmLabel: "Переименовать",
|
||
});
|
||
|
||
if (!nextName || nextName === entry.name) return;
|
||
|
||
const payload = await postMediaAction("/api/media/rename", { path: entry.path, name: nextName });
|
||
applyMediaReferenceReplacements(payload.replacements);
|
||
await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" });
|
||
const renamedEntry = findEntryByPath(payload.path);
|
||
if (renamedEntry) {
|
||
state.mediaLibrary.selectedPath = parentMediaPath(payload.path);
|
||
state.mediaLibrary.selectedEntry = renamedEntry;
|
||
state.mediaLibrary.selectedUrl = renamedEntry.type === "file" ? renamedEntry.url || "" : "";
|
||
renderMediaLibrary();
|
||
}
|
||
const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0;
|
||
setStatus(`Переименовано: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`);
|
||
}
|
||
|
||
async function duplicateMediaEntry(entry) {
|
||
if (!entry || state.mediaLibrary.trashMode) return;
|
||
|
||
const payload = await postMediaAction("/api/media/duplicate", { path: entry.path });
|
||
await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" });
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
if (!payload.url) {
|
||
state.mediaLibrary.selectedEntry = findEntryByPath(payload.path);
|
||
state.mediaLibrary.selectedUrl = "";
|
||
renderMediaLibrary();
|
||
}
|
||
setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} продублирован: ${payload.path}.`);
|
||
}
|
||
|
||
async function uploadIntoMediaLibrary(file) {
|
||
const context = state.mediaLibrary.context;
|
||
if (!context || !file) return;
|
||
|
||
await uploadMediaFile({
|
||
...context,
|
||
file,
|
||
afterUpload: async (payload) => {
|
||
state.mediaLibrary.selectedUrl = payload.url;
|
||
const uploadedPath = pathFromUrl(payload.url);
|
||
await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url });
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
},
|
||
});
|
||
}
|
||
|
||
function openMediaLibraryUploader() {
|
||
const context = state.mediaLibrary.context;
|
||
if (!context) return;
|
||
|
||
openMediaFilePicker({
|
||
...context,
|
||
afterUpload: async (payload) => {
|
||
state.mediaLibrary.selectedUrl = payload.url;
|
||
const uploadedPath = pathFromUrl(payload.url);
|
||
await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url });
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
},
|
||
});
|
||
}
|
||
|
||
async function deleteSelectedTrashFile() {
|
||
const file = selectedMediaFile();
|
||
if (!file?.trashPath) return;
|
||
|
||
const nextSelectedPath = adjacentMediaEntryPath(file);
|
||
const confirmed = await requestDeleteConfirmation({
|
||
title: "Удалить из корзины навсегда?",
|
||
body: "Этот файл будет удалён окончательно. Восстановить его через админку уже не получится.",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
await postMediaAction("/api/media/trash/delete", { trashPath: file.trashPath });
|
||
await loadMediaTrash({ selectedTrashPath: nextSelectedPath });
|
||
setStatus(`Файл удалён из корзины навсегда: ${file.originalPath || file.path}.`);
|
||
}
|
||
|
||
async function deleteSelectedMediaEntry() {
|
||
const entry = state.mediaLibrary.selectedEntry;
|
||
if (!entry || el.mediaDelete.disabled) return;
|
||
|
||
if (state.mediaLibrary.trashMode) {
|
||
await deleteSelectedTrashFile();
|
||
return;
|
||
}
|
||
|
||
const parentPath = entry.type === "file" ? state.mediaLibrary.selectedPath || parentMediaPath(pathFromUrl(entry.url)) : parentMediaPath(entry.path);
|
||
const nextSelectedPath = adjacentMediaEntryPath(entry);
|
||
const confirmed = await requestDeleteConfirmation({
|
||
title: entry.type === "directory" ? "Удалить папку?" : "Удалить unused-файл?",
|
||
body:
|
||
entry.type === "directory"
|
||
? "Папка будет перенесена в assets/.trash. Если внутри есть файлы, которые используются в модели или текущей верстке, сервер остановит удаление."
|
||
: "Файл будет перенесён в assets/.trash. Файлы, которые используются в модели или текущей верстке, сервер не удаляет.",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
const payload = await postMediaAction("/api/media/delete-entry", { path: entry.path });
|
||
await loadMediaColumns(parentPath, { selectedUrl: "" });
|
||
await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`));
|
||
|
||
const nextEntry = nextSelectedPath ? findEntryByPath(nextSelectedPath) : null;
|
||
if (nextEntry) {
|
||
state.mediaLibrary.selectedPath = parentPath;
|
||
state.mediaLibrary.selectedEntry = nextEntry;
|
||
state.mediaLibrary.selectedUrl = nextEntry.type === "file" ? nextEntry.url || "" : "";
|
||
renderMediaLibrary();
|
||
}
|
||
|
||
setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} перенесён в ${payload.trashPath}.`);
|
||
}
|
||
|
||
function renderMediaField({ block, editableField, grid }) {
|
||
const value = getByPath(block, editableField.path) ?? "";
|
||
const container = document.createElement("div");
|
||
const labelRow = document.createElement("div");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const hiddenInput = document.createElement("input");
|
||
const control = document.createElement("div");
|
||
const fileControl = document.createElement("div");
|
||
const fileButton = document.createElement("button");
|
||
const fileName = document.createElement("span");
|
||
const urlInput = document.createElement("input");
|
||
const sourceSwitch = document.createElement("div");
|
||
const fileSourceButton = document.createElement("button");
|
||
const urlSourceButton = document.createElement("button");
|
||
const preview = document.createElement("div");
|
||
const path = document.createElement("span");
|
||
const hint = document.createElement("span");
|
||
const error = document.createElement("span");
|
||
let source = /^(https?:)?\/\//i.test(value) ? "url" : "file";
|
||
|
||
function setSource(nextSource) {
|
||
source = nextSource;
|
||
fileControl.hidden = source !== "file";
|
||
urlInput.hidden = source !== "url";
|
||
fileSourceButton.dataset.active = String(source === "file");
|
||
urlSourceButton.dataset.active = String(source === "url");
|
||
}
|
||
|
||
container.className = "field-control wide service-media-field";
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = editableField.label || editableField.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = editableField.kind || "media";
|
||
labelRow.append(labelText, kind);
|
||
|
||
hiddenInput.className = "service-media-hidden-input";
|
||
hiddenInput.type = "hidden";
|
||
hiddenInput.dataset.path = editableField.path;
|
||
hiddenInput.value = value;
|
||
|
||
control.className = "service-media-control";
|
||
|
||
fileControl.className = "service-media-file-control";
|
||
fileButton.className = "service-media-file-button";
|
||
fileButton.type = "button";
|
||
fileButton.textContent = "Выберите файл";
|
||
fileName.className = "service-media-file-name";
|
||
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
|
||
fileName.title = fileNameFromUrl(value);
|
||
fileControl.append(fileButton, fileName);
|
||
|
||
urlInput.className = "service-media-url-input";
|
||
urlInput.type = "text";
|
||
urlInput.placeholder = "https://...";
|
||
urlInput.autocomplete = "off";
|
||
urlInput.value = value;
|
||
|
||
sourceSwitch.className = "service-media-source-switch";
|
||
sourceSwitch.setAttribute("aria-label", `${editableField.label || editableField.path}: источник`);
|
||
fileSourceButton.className = "service-media-source-button";
|
||
fileSourceButton.type = "button";
|
||
fileSourceButton.title = "Файл с диска";
|
||
fileSourceButton.setAttribute("aria-label", "Файл с диска");
|
||
fileSourceButton.textContent = "HD";
|
||
urlSourceButton.className = "service-media-source-button";
|
||
urlSourceButton.type = "button";
|
||
urlSourceButton.title = "Внешняя ссылка";
|
||
urlSourceButton.setAttribute("aria-label", "Внешняя ссылка");
|
||
urlSourceButton.textContent = "URL";
|
||
sourceSwitch.append(fileSourceButton, urlSourceButton);
|
||
|
||
preview.className = "service-media-preview";
|
||
renderMediaPreview(preview, value, hint, editableField);
|
||
|
||
path.className = "field-path";
|
||
path.textContent = `${editableField.path} -> assets/uploads/${uploadBucketForField(block, editableField)}`;
|
||
hint.className = "media-upload-hint";
|
||
error.className = "media-upload-error";
|
||
|
||
fileSourceButton.addEventListener("click", () => setSource("file"));
|
||
urlSourceButton.addEventListener("click", () => {
|
||
setSource("url");
|
||
urlInput.focus();
|
||
});
|
||
|
||
urlInput.addEventListener("input", () => {
|
||
updateMediaFieldValue({
|
||
block,
|
||
editableField,
|
||
hiddenInput,
|
||
urlInput,
|
||
fileName,
|
||
preview,
|
||
hint,
|
||
value: urlInput.value,
|
||
});
|
||
});
|
||
|
||
fileButton.addEventListener("click", () => {
|
||
openMediaLibrary({
|
||
block,
|
||
editableField,
|
||
hiddenInput,
|
||
urlInput,
|
||
fileName,
|
||
preview,
|
||
hint,
|
||
error,
|
||
setSource,
|
||
}).catch((errorMessage) => setStatus(`Ошибка медиатеки: ${errorMessage.message}`));
|
||
});
|
||
|
||
setSource(source);
|
||
control.append(fileControl, urlInput, sourceSwitch, preview);
|
||
container.append(labelRow, hiddenInput, control, path, hint, error);
|
||
grid.append(container);
|
||
}
|
||
|
||
function collectionItems(block, editableField) {
|
||
const value = getByPath(block, editableField.path);
|
||
if (Array.isArray(value)) return value;
|
||
|
||
const nextValue = [];
|
||
setByPath(block, editableField.path, nextValue);
|
||
return nextValue;
|
||
}
|
||
|
||
function collectionItemTitle(item, index) {
|
||
return item?.adminLabel || item?.title || item?.name || item?.label || item?.question || item?.windowTitle || `Элемент ${index + 1}`;
|
||
}
|
||
|
||
function collectionItemTemplate(editableField) {
|
||
if (editableField.itemTemplate) {
|
||
const item = clone(editableField.itemTemplate);
|
||
if (editableField.path === "content.dock.items") {
|
||
item.iconSrc = "";
|
||
item.iconAlt = "";
|
||
item.iconClass = "";
|
||
}
|
||
return item;
|
||
}
|
||
|
||
const item = {};
|
||
for (const itemField of editableField.itemFields ?? []) {
|
||
setByPath(item, itemField.path, itemField.defaultValue ?? "");
|
||
}
|
||
return item;
|
||
}
|
||
|
||
function uniqueCollectionItemId(items, baseId) {
|
||
const root = String(baseId || "item").trim() || "item";
|
||
const ids = new Set(items.map((item) => item?.id).filter(Boolean));
|
||
if (!ids.has(root)) return root;
|
||
|
||
let index = 2;
|
||
while (ids.has(`${root}-${index}`)) {
|
||
index += 1;
|
||
}
|
||
return `${root}-${index}`;
|
||
}
|
||
|
||
function prepareCollectionItemForInsert(items, item) {
|
||
const nextItem = clone(item);
|
||
|
||
if (Object.prototype.hasOwnProperty.call(nextItem, "id")) {
|
||
nextItem.id = uniqueCollectionItemId(items, nextItem.id);
|
||
}
|
||
|
||
return nextItem;
|
||
}
|
||
|
||
function collectionInstanceKey(block, editableField) {
|
||
return `${block?.id || "static-elements"}::${editableField.path}`;
|
||
}
|
||
|
||
function stableCollectionPart(value) {
|
||
return String(value || "")
|
||
.normalize("NFKD")
|
||
.replace(/[^\w.-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.toLowerCase()
|
||
.slice(0, 80);
|
||
}
|
||
|
||
function collectionItemKey(item, index) {
|
||
if (item && typeof item === "object") {
|
||
if (item.id) return `id:${item.id}`;
|
||
|
||
const stableIdentity = item.targetId || item.href || "";
|
||
const stableIdentityPart = stableCollectionPart(stableIdentity);
|
||
if (stableIdentityPart) return `item:${stableIdentityPart}`;
|
||
|
||
const stableTitle = item.title || item.name || item.label || item.question || item.windowTitle || item.adminLabel || "";
|
||
const stableTitlePart = stableCollectionPart(stableTitle);
|
||
if (stableTitlePart) return `item:${stableTitlePart}`;
|
||
|
||
let runtimeKey = collectionItemRuntimeKeys.get(item);
|
||
if (!runtimeKey) {
|
||
collectionItemRuntimeKeyCounter += 1;
|
||
runtimeKey = `runtime:${collectionItemRuntimeKeyCounter}`;
|
||
collectionItemRuntimeKeys.set(item, runtimeKey);
|
||
}
|
||
|
||
return runtimeKey;
|
||
}
|
||
|
||
return `value:${index}:${String(item)}`;
|
||
}
|
||
|
||
function persistCollectionOpenState() {
|
||
const collections = {};
|
||
|
||
for (const [key, openKeys] of state.collectionOpenState.entries()) {
|
||
collections[key] = [...openKeys];
|
||
}
|
||
|
||
updateAdminLayoutState({ collections });
|
||
}
|
||
|
||
function collectionOpenKeys(block, editableField, items) {
|
||
const key = collectionInstanceKey(block, editableField);
|
||
let openKeys = state.collectionOpenState.get(key);
|
||
|
||
if (!openKeys) {
|
||
openKeys = new Set();
|
||
if (items.length > 0 && items.length <= 8) {
|
||
openKeys.add(collectionItemKey(items[0], 0));
|
||
}
|
||
state.collectionOpenState.set(key, openKeys);
|
||
persistCollectionOpenState();
|
||
}
|
||
|
||
return openKeys;
|
||
}
|
||
|
||
function fieldInputValue(input) {
|
||
if (input.type === "checkbox") return input.checked;
|
||
|
||
if (input.dataset.valueType === "number" || input.type === "range" || input.type === "number") {
|
||
const value = Number.parseFloat(input.value);
|
||
return Number.isFinite(value) ? value : 0;
|
||
}
|
||
|
||
return input.value;
|
||
}
|
||
|
||
function syncFieldValue(block, path, input) {
|
||
setByPath(block, path, fieldInputValue(input));
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
markDirty();
|
||
}
|
||
|
||
function renderBooleanField({ block, editableField, grid }) {
|
||
const label = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const path = document.createElement("span");
|
||
const input = document.createElement("input");
|
||
|
||
label.className = "field-control boolean-field";
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = editableField.label || editableField.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = editableField.kind || "boolean";
|
||
path.className = "field-path";
|
||
path.textContent = editableField.path;
|
||
labelRow.append(labelText, kind);
|
||
|
||
input.dataset.path = editableField.path;
|
||
input.type = "checkbox";
|
||
input.checked = Boolean(getByPath(block, editableField.path));
|
||
|
||
input.addEventListener("change", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
syncFieldValue(active, editableField.path, input);
|
||
});
|
||
|
||
label.append(labelRow, input, path);
|
||
grid.append(label);
|
||
}
|
||
|
||
function formatRangeValue(value, editableField) {
|
||
const step = Number.parseFloat(editableField.step ?? 1);
|
||
const precision = Number.isFinite(step) && step > 0 && step < 1 ? String(step).split(".")[1]?.length || 0 : 0;
|
||
return `${Number(value).toFixed(precision)}${editableField.suffix || ""}`;
|
||
}
|
||
|
||
function renderRangeField({ block, editableField, grid }) {
|
||
const label = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const path = document.createElement("span");
|
||
const control = document.createElement("span");
|
||
const input = document.createElement("input");
|
||
const output = document.createElement("span");
|
||
const min = Number.parseFloat(editableField.min ?? 0);
|
||
const max = Number.parseFloat(editableField.max ?? 1);
|
||
const step = Number.parseFloat(editableField.step ?? 0.01);
|
||
const rawValue = Number.parseFloat(getByPath(block, editableField.path));
|
||
const value = Number.isFinite(rawValue) ? rawValue : min;
|
||
|
||
label.className = "field-control range-field wide";
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = editableField.label || editableField.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = editableField.kind || "range";
|
||
path.className = "field-path";
|
||
path.textContent = editableField.path;
|
||
control.className = "range-control";
|
||
output.className = "range-value";
|
||
labelRow.append(labelText, kind);
|
||
|
||
input.dataset.path = editableField.path;
|
||
input.dataset.valueType = "number";
|
||
input.type = "range";
|
||
input.min = Number.isFinite(min) ? String(min) : "0";
|
||
input.max = Number.isFinite(max) ? String(max) : "1";
|
||
input.step = Number.isFinite(step) ? String(step) : "0.01";
|
||
input.value = String(value);
|
||
output.textContent = formatRangeValue(value, editableField);
|
||
|
||
input.addEventListener("input", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
output.textContent = formatRangeValue(input.value, editableField);
|
||
syncFieldValue(active, editableField.path, input);
|
||
});
|
||
|
||
control.append(input, output);
|
||
label.append(labelRow, control, path);
|
||
grid.append(label);
|
||
}
|
||
|
||
function renderNumberField({ block, editableField, grid }) {
|
||
const label = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const path = document.createElement("span");
|
||
const control = document.createElement("span");
|
||
const input = document.createElement("input");
|
||
const suffix = document.createElement("span");
|
||
const rawValue = Number.parseFloat(getByPath(block, editableField.path));
|
||
const fallback = Number.parseFloat(editableField.defaultValue ?? editableField.min ?? 0);
|
||
const value = Number.isFinite(rawValue) ? rawValue : fallback;
|
||
|
||
label.className = "field-control number-field";
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = editableField.label || editableField.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = editableField.kind || "number";
|
||
path.className = "field-path";
|
||
path.textContent = editableField.path;
|
||
control.className = "number-control";
|
||
suffix.className = "number-suffix";
|
||
suffix.textContent = editableField.suffix || "";
|
||
labelRow.append(labelText, kind);
|
||
|
||
input.dataset.path = editableField.path;
|
||
input.dataset.valueType = "number";
|
||
input.type = "number";
|
||
input.value = Number.isFinite(value) ? String(value) : "0";
|
||
input.autocomplete = "off";
|
||
|
||
if (editableField.min != null) input.min = String(editableField.min);
|
||
if (editableField.max != null) input.max = String(editableField.max);
|
||
if (editableField.step != null) input.step = String(editableField.step);
|
||
|
||
input.addEventListener("input", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
syncFieldValue(active, editableField.path, input);
|
||
});
|
||
|
||
control.append(input);
|
||
if (suffix.textContent) control.append(suffix);
|
||
label.append(labelRow, control, path);
|
||
grid.append(label);
|
||
}
|
||
|
||
function siblingFieldPath(path, siblingKey) {
|
||
const keys = path.split(".");
|
||
keys[keys.length - 1] = siblingKey;
|
||
return keys.join(".");
|
||
}
|
||
|
||
function renderLinkModeField({ block, field, grid }) {
|
||
const container = document.createElement("div");
|
||
const labelRow = document.createElement("div");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const hiddenMode = document.createElement("input");
|
||
const rows = document.createElement("div");
|
||
const path = document.createElement("span");
|
||
const hrefPath = siblingFieldPath(field.path, field.hrefPath || "href");
|
||
const targetPath = siblingFieldPath(field.path, field.targetPath || "target");
|
||
const currentMode = getByPath(block, field.path) === "href" ? "href" : "target";
|
||
|
||
function createRow({ mode, title, inputPath, placeholder }) {
|
||
const row = document.createElement("label");
|
||
const check = document.createElement("input");
|
||
const caption = document.createElement("span");
|
||
const input = document.createElement("input");
|
||
|
||
row.className = "link-mode-row";
|
||
check.type = "checkbox";
|
||
check.checked = currentMode === mode;
|
||
check.setAttribute("aria-label", `${title}: использовать`);
|
||
caption.textContent = title;
|
||
input.dataset.path = inputPath;
|
||
input.type = "text";
|
||
input.autocomplete = "off";
|
||
input.placeholder = placeholder;
|
||
input.value = getByPath(block, inputPath) ?? "";
|
||
|
||
check.addEventListener("change", () => {
|
||
if (!check.checked) {
|
||
check.checked = true;
|
||
return;
|
||
}
|
||
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
hiddenMode.value = mode;
|
||
setByPath(active, field.path, mode);
|
||
rows.querySelectorAll('input[type="checkbox"]').forEach((candidate) => {
|
||
candidate.checked = candidate === check;
|
||
});
|
||
el.json.value = JSON.stringify(active, null, 2);
|
||
markDirty(mode === "href" ? "Активен внешний адрес." : "Активен Target.");
|
||
});
|
||
|
||
input.addEventListener("input", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
syncFieldValue(active, inputPath, input);
|
||
});
|
||
|
||
row.append(check, caption, input);
|
||
return row;
|
||
}
|
||
|
||
container.className = "field-control wide link-mode-field";
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = field.label || "Источник ссылки";
|
||
kind.className = "field-kind";
|
||
kind.textContent = "link";
|
||
labelRow.append(labelText, kind);
|
||
|
||
hiddenMode.type = "hidden";
|
||
hiddenMode.dataset.path = field.path;
|
||
hiddenMode.value = currentMode;
|
||
rows.className = "link-mode-grid";
|
||
rows.append(
|
||
createRow({ mode: "href", title: "Адрес", inputPath: hrefPath, placeholder: "https://..." }),
|
||
createRow({ mode: "target", title: "Target", inputPath: targetPath, placeholder: "index.html#section" }),
|
||
);
|
||
|
||
path.className = "field-path";
|
||
path.textContent = `${field.path} · ${hrefPath} · ${targetPath}`;
|
||
container.append(labelRow, hiddenMode, rows, path);
|
||
grid.append(container);
|
||
}
|
||
|
||
function renderHeaderBooleanToggle({ block, editableField, head }) {
|
||
const label = document.createElement("label");
|
||
const input = document.createElement("input");
|
||
const text = document.createElement("span");
|
||
|
||
label.className = "group-head-toggle";
|
||
input.dataset.path = editableField.path;
|
||
input.type = "checkbox";
|
||
input.checked = Boolean(getByPath(block, editableField.path));
|
||
text.textContent = "Отображение";
|
||
|
||
input.addEventListener("change", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
syncFieldValue(active, editableField.path, input);
|
||
});
|
||
|
||
label.append(input, text);
|
||
head.append(label);
|
||
}
|
||
|
||
function renderCollectionScalarField({ block, field, grid }) {
|
||
if (field.kind === "link-mode") {
|
||
renderLinkModeField({ block, field, grid });
|
||
return;
|
||
}
|
||
|
||
if (field.kind === "boolean") {
|
||
renderBooleanField({ block, editableField: field, grid });
|
||
return;
|
||
}
|
||
|
||
if (field.kind === "range") {
|
||
renderRangeField({ block, editableField: field, grid });
|
||
return;
|
||
}
|
||
|
||
if (field.kind === "number") {
|
||
renderNumberField({ block, editableField: field, grid });
|
||
return;
|
||
}
|
||
|
||
const label = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const path = document.createElement("span");
|
||
const value = getByPath(block, field.path) ?? "";
|
||
const isLong = field.kind === "html" || field.kind === "textarea";
|
||
const input = isLong ? document.createElement("textarea") : document.createElement("input");
|
||
|
||
label.className = `field-control${isLong ? " wide" : ""}`;
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = field.label || field.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = field.kind || "text";
|
||
path.className = "field-path";
|
||
path.textContent = field.path;
|
||
labelRow.append(labelText, kind);
|
||
|
||
input.dataset.path = field.path;
|
||
input.value = value;
|
||
|
||
if (!isLong) {
|
||
input.type = "text";
|
||
input.autocomplete = "off";
|
||
}
|
||
|
||
input.addEventListener("input", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
syncFieldValue(active, field.path, input);
|
||
});
|
||
|
||
label.append(labelRow, input, path);
|
||
grid.append(label);
|
||
}
|
||
|
||
function startCollectionPointerDrag(event, card, items, index, rerender) {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const list = card.closest(".collection-list");
|
||
if (!list) return;
|
||
|
||
const placeholder = createSortablePlaceholder(card);
|
||
const ghost = createSortableGhost(card, event);
|
||
|
||
card.classList.add("dragging", "sorting-source");
|
||
list.classList.add("sorting-active");
|
||
card.after(placeholder);
|
||
|
||
const onMove = (moveEvent) => {
|
||
moveSortableGhost(ghost, moveEvent);
|
||
movePlaceholderByPoint({
|
||
event: moveEvent,
|
||
list,
|
||
source: card,
|
||
placeholder,
|
||
selector: ".collection-item-card",
|
||
});
|
||
};
|
||
|
||
const onFinish = () => {
|
||
document.removeEventListener("pointermove", onMove);
|
||
document.removeEventListener("pointerup", onFinish);
|
||
document.removeEventListener("pointercancel", onCancel);
|
||
|
||
const nextIndex = sortablePlaceholderIndex({
|
||
list,
|
||
placeholder,
|
||
source: card,
|
||
selector: ".collection-item-card",
|
||
});
|
||
|
||
cleanupDragState();
|
||
|
||
if (nextIndex < 0 || nextIndex === index) return;
|
||
const [moved] = items.splice(index, 1);
|
||
items.splice(Math.min(nextIndex, items.length), 0, moved);
|
||
rerender("Порядок элементов коллекции изменён локально.");
|
||
};
|
||
|
||
const onCancel = () => {
|
||
document.removeEventListener("pointermove", onMove);
|
||
document.removeEventListener("pointerup", onFinish);
|
||
document.removeEventListener("pointercancel", onCancel);
|
||
cleanupDragState();
|
||
};
|
||
|
||
document.addEventListener("pointermove", onMove);
|
||
document.addEventListener("pointerup", onFinish);
|
||
document.addEventListener("pointercancel", onCancel);
|
||
}
|
||
|
||
function renderCollectionField({ block, editableField, grid }) {
|
||
const items = collectionItems(block, editableField);
|
||
const openKeys = collectionOpenKeys(block, editableField, items);
|
||
const container = document.createElement("section");
|
||
const head = document.createElement("div");
|
||
const title = document.createElement("div");
|
||
const addButton = document.createElement("button");
|
||
const list = document.createElement("div");
|
||
|
||
function rerender(message = null) {
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
renderContentFields(block);
|
||
markDirty(message);
|
||
}
|
||
|
||
container.className = "collection-field";
|
||
head.className = "collection-head";
|
||
title.className = "collection-title";
|
||
title.textContent = editableField.label || editableField.path;
|
||
addButton.className = "collection-add-btn";
|
||
addButton.type = "button";
|
||
addButton.title = `Добавить элемент в «${editableField.label || editableField.path}»`;
|
||
addButton.setAttribute("aria-label", addButton.title);
|
||
addButton.textContent = "+";
|
||
addButton.addEventListener("click", () => {
|
||
const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField));
|
||
items.push(nextItem);
|
||
openKeys.add(collectionItemKey(nextItem, items.length - 1));
|
||
persistCollectionOpenState();
|
||
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
|
||
});
|
||
head.append(title, addButton);
|
||
|
||
list.className = "collection-list";
|
||
|
||
items.forEach((item, index) => {
|
||
const itemKey = collectionItemKey(item, index);
|
||
const details = document.createElement("details");
|
||
const summary = document.createElement("summary");
|
||
const summaryTitle = document.createElement("span");
|
||
const controls = document.createElement("span");
|
||
const dragHandle = document.createElement("button");
|
||
const body = document.createElement("div");
|
||
const itemGrid = document.createElement("div");
|
||
const hasVisibilityToggle =
|
||
item &&
|
||
typeof item === "object" &&
|
||
(Object.prototype.hasOwnProperty.call(item, "enabled") ||
|
||
Object.prototype.hasOwnProperty.call(editableField.itemTemplate ?? {}, "enabled"));
|
||
|
||
details.className = "collection-item-card";
|
||
details.classList.toggle("is-hidden", item?.enabled === false);
|
||
details.open = openKeys.has(itemKey);
|
||
details.addEventListener("toggle", () => {
|
||
if (details.open) openKeys.add(itemKey);
|
||
else openKeys.delete(itemKey);
|
||
persistCollectionOpenState();
|
||
});
|
||
summary.className = "collection-summary";
|
||
summaryTitle.className = "collection-summary-title";
|
||
summaryTitle.textContent = collectionItemTitle(item, index);
|
||
controls.className = "collection-item-actions";
|
||
dragHandle.className = "collection-drag-handle";
|
||
dragHandle.type = "button";
|
||
dragHandle.title = "Перетащить элемент";
|
||
dragHandle.setAttribute("aria-label", "Перетащить элемент");
|
||
dragHandle.innerHTML =
|
||
'<svg viewBox="0 0 12 18" aria-hidden="true" focusable="false"><circle cx="4" cy="4" r="1.25"></circle><circle cx="8" cy="4" r="1.25"></circle><circle cx="4" cy="9" r="1.25"></circle><circle cx="8" cy="9" r="1.25"></circle><circle cx="4" cy="14" r="1.25"></circle><circle cx="8" cy="14" r="1.25"></circle></svg>';
|
||
dragHandle.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
});
|
||
dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender));
|
||
|
||
if (hasVisibilityToggle) {
|
||
if (!Object.prototype.hasOwnProperty.call(item, "enabled")) item.enabled = true;
|
||
|
||
const visibilityToggle = document.createElement("span");
|
||
const visibilityInput = document.createElement("input");
|
||
const visibilityText = document.createElement("span");
|
||
|
||
visibilityToggle.className = "collection-visibility-toggle";
|
||
visibilityToggle.title = "Скрыть/показать";
|
||
visibilityToggle.setAttribute("aria-label", "Скрыть/показать");
|
||
visibilityInput.type = "checkbox";
|
||
visibilityInput.checked = item.enabled !== false;
|
||
visibilityText.textContent = "Показать";
|
||
visibilityToggle.addEventListener("click", (event) => event.stopPropagation());
|
||
visibilityInput.addEventListener("click", (event) => event.stopPropagation());
|
||
visibilityInput.addEventListener("change", () => {
|
||
item.enabled = visibilityInput.checked;
|
||
rerender(visibilityInput.checked ? "Элемент коллекции показан локально." : "Элемент коллекции скрыт локально.");
|
||
});
|
||
visibilityToggle.append(visibilityInput, visibilityText);
|
||
controls.append(visibilityToggle);
|
||
}
|
||
|
||
[
|
||
["↑", "Поднять", () => {
|
||
if (index === 0) return;
|
||
const [moved] = items.splice(index, 1);
|
||
items.splice(index - 1, 0, moved);
|
||
persistCollectionOpenState();
|
||
rerender("Порядок элементов коллекции изменён локально.");
|
||
}],
|
||
["↓", "Опустить", () => {
|
||
if (index >= items.length - 1) return;
|
||
const [moved] = items.splice(index, 1);
|
||
items.splice(index + 1, 0, moved);
|
||
persistCollectionOpenState();
|
||
rerender("Порядок элементов коллекции изменён локально.");
|
||
}],
|
||
["⧉", "Дублировать", () => {
|
||
const nextItem = prepareCollectionItemForInsert(items, item);
|
||
items.splice(index + 1, 0, nextItem);
|
||
if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1));
|
||
persistCollectionOpenState();
|
||
rerender("Элемент коллекции продублирован локально.");
|
||
}],
|
||
[FINDER_TRASH_ICON_SVG, "Удалить", async () => {
|
||
const confirmed = await requestDeleteConfirmation();
|
||
if (!confirmed) return;
|
||
openKeys.delete(itemKey);
|
||
items.splice(index, 1);
|
||
persistCollectionOpenState();
|
||
rerender("Элемент коллекции удалён локально.");
|
||
}],
|
||
].forEach(([symbol, titleText, handler]) => {
|
||
const button = document.createElement("button");
|
||
button.className = "collection-icon-btn";
|
||
button.type = "button";
|
||
button.title = titleText;
|
||
button.setAttribute("aria-label", titleText);
|
||
if (titleText === "Удалить") {
|
||
button.innerHTML = symbol;
|
||
} else {
|
||
button.textContent = symbol;
|
||
}
|
||
button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`));
|
||
});
|
||
controls.append(button);
|
||
});
|
||
controls.append(dragHandle);
|
||
|
||
summary.append(summaryTitle, controls);
|
||
body.className = "collection-item-body";
|
||
itemGrid.className = "group-grid";
|
||
|
||
for (const itemField of editableField.itemFields ?? []) {
|
||
const nestedField = {
|
||
...itemField,
|
||
path: `${editableField.path}.${index}.${itemField.path}`,
|
||
label: itemField.label || itemField.path,
|
||
group: editableField.group,
|
||
};
|
||
|
||
if (nestedField.kind === "media") {
|
||
renderMediaField({ block, editableField: nestedField, grid: itemGrid });
|
||
} else {
|
||
renderCollectionScalarField({ block, field: nestedField, grid: itemGrid });
|
||
}
|
||
}
|
||
|
||
body.append(itemGrid);
|
||
details.append(summary, body);
|
||
list.append(details);
|
||
});
|
||
|
||
container.append(head, list);
|
||
grid.append(container);
|
||
}
|
||
|
||
function renderContentFields(block) {
|
||
el.contentFields.innerHTML = "";
|
||
|
||
if (!block.editableFields?.length) {
|
||
const note = document.createElement("div");
|
||
note.className = "empty-note";
|
||
note.textContent = "Для этого блока typed-поля ещё не выделены. Пока доступно редактирование через JSON.";
|
||
el.contentFields.append(note);
|
||
return;
|
||
}
|
||
|
||
for (const [groupName, fields] of groupEditableFields(block)) {
|
||
const group = document.createElement("section");
|
||
const head = document.createElement("div");
|
||
const titleWrap = document.createElement("div");
|
||
const title = document.createElement("h3");
|
||
const desc = document.createElement("p");
|
||
const grid = document.createElement("div");
|
||
|
||
group.className = "content-group";
|
||
head.className = "group-head";
|
||
title.textContent = groupName;
|
||
desc.className = "group-desc";
|
||
desc.textContent = GROUP_COPY[groupName] || GROUP_COPY["Контент"];
|
||
titleWrap.append(title, desc);
|
||
head.append(titleWrap);
|
||
grid.className = "group-grid";
|
||
|
||
const headerFields = fields.filter((field) => field.kind === "boolean" && field.headerToggle);
|
||
const bodyFields = fields.filter((field) => !headerFields.includes(field));
|
||
headerFields.forEach((editableField) => renderHeaderBooleanToggle({ block, editableField, head }));
|
||
|
||
for (const editableField of bodyFields) {
|
||
if (editableField.kind === "collection") {
|
||
renderCollectionField({ block, editableField, grid });
|
||
continue;
|
||
}
|
||
|
||
if (editableField.kind === "media") {
|
||
renderMediaField({ block, editableField, grid });
|
||
continue;
|
||
}
|
||
|
||
if (editableField.kind === "link-mode") {
|
||
renderLinkModeField({ block, field: editableField, grid });
|
||
continue;
|
||
}
|
||
|
||
if (editableField.kind === "boolean") {
|
||
renderBooleanField({ block, editableField, grid });
|
||
continue;
|
||
}
|
||
|
||
if (editableField.kind === "range") {
|
||
renderRangeField({ block, editableField, grid });
|
||
continue;
|
||
}
|
||
|
||
if (editableField.kind === "number") {
|
||
renderNumberField({ block, editableField, grid });
|
||
continue;
|
||
}
|
||
|
||
const label = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const path = document.createElement("span");
|
||
const value = getByPath(block, editableField.path) ?? "";
|
||
const isLong = editableField.kind === "html" || editableField.kind === "textarea";
|
||
const input = isLong ? document.createElement("textarea") : document.createElement("input");
|
||
|
||
label.className = `field-control${isLong ? " wide" : ""}`;
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = editableField.label || editableField.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = editableField.kind || "text";
|
||
path.className = "field-path";
|
||
path.textContent = editableField.path;
|
||
labelRow.append(labelText, kind);
|
||
|
||
input.dataset.path = editableField.path;
|
||
input.value = value;
|
||
|
||
if (!isLong) {
|
||
input.type = "text";
|
||
input.autocomplete = "off";
|
||
}
|
||
|
||
input.addEventListener("input", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
syncFieldValue(active, editableField.path, input);
|
||
});
|
||
|
||
label.append(labelRow, input, path);
|
||
grid.append(label);
|
||
}
|
||
|
||
group.append(head, grid);
|
||
el.contentFields.append(group);
|
||
}
|
||
}
|
||
|
||
function syncContentFields() {
|
||
const block = activeBlock();
|
||
if (!block) return;
|
||
|
||
for (const input of el.contentFields.querySelectorAll("[data-path]")) {
|
||
setByPath(block, input.dataset.path, fieldInputValue(input));
|
||
}
|
||
}
|
||
|
||
function syncBlockFromJson() {
|
||
if (!state.selected) return false;
|
||
|
||
try {
|
||
const parsed = JSON.parse(el.json.value);
|
||
if (state.mode === "seo" && isStaticSelection()) {
|
||
state.page.seo = parsed;
|
||
} else if (isStaticSelection()) {
|
||
state.page.staticElements = parsed;
|
||
} else {
|
||
const region = activeRegion();
|
||
if (!region) return false;
|
||
region.blocks[state.selected.blockIndex] = parsed;
|
||
}
|
||
renderAll();
|
||
markDirty("JSON блока применён локально. Нажмите «Сохранить модель», чтобы записать файл.");
|
||
return true;
|
||
} catch (error) {
|
||
setStatus(`Ошибка JSON: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function knowledgeTree() {
|
||
if (!state.knowledge) return [];
|
||
if (!Array.isArray(state.knowledge.tree)) state.knowledge.tree = [];
|
||
return state.knowledge.tree;
|
||
}
|
||
|
||
function knowledgeWalk(nodes = knowledgeTree(), parent = null, callback = () => {}) {
|
||
nodes.forEach((node, index) => {
|
||
callback(node, parent, nodes, index);
|
||
knowledgeWalk(Array.isArray(node.children) ? node.children : [], node, callback);
|
||
});
|
||
}
|
||
|
||
function allKnowledgeIds() {
|
||
const ids = new Set();
|
||
knowledgeWalk(knowledgeTree(), null, (node) => {
|
||
if (node.id) ids.add(node.id);
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
function findKnowledgeNode(id) {
|
||
let found = null;
|
||
let foundParent = null;
|
||
let foundSiblings = null;
|
||
let foundIndex = -1;
|
||
|
||
knowledgeWalk(knowledgeTree(), null, (node, parent, siblings, index) => {
|
||
if (found || node.id !== id) return;
|
||
found = node;
|
||
foundParent = parent;
|
||
foundSiblings = siblings;
|
||
foundIndex = index;
|
||
});
|
||
|
||
return found ? { node: found, parent: foundParent, siblings: foundSiblings, index: foundIndex } : null;
|
||
}
|
||
|
||
function isKnowledgeRootId(id) {
|
||
return id === KNOWLEDGE_ROOT_ID;
|
||
}
|
||
|
||
function isKnowledgeRootSelected() {
|
||
return isKnowledgeRootId(state.knowledgeSelectedId);
|
||
}
|
||
|
||
function knowledgeRootTitle() {
|
||
return state.knowledge?.title || state.knowledge?.settings?.navTitle || "Knowledge";
|
||
}
|
||
|
||
function selectedKnowledgeNode() {
|
||
if (isKnowledgeRootSelected()) return state.knowledge;
|
||
return findKnowledgeNode(state.knowledgeSelectedId)?.node || null;
|
||
}
|
||
|
||
function knowledgeSlug(value, fallback = "material") {
|
||
const map = {
|
||
а: "a",
|
||
б: "b",
|
||
в: "v",
|
||
г: "g",
|
||
д: "d",
|
||
е: "e",
|
||
ё: "e",
|
||
ж: "zh",
|
||
з: "z",
|
||
и: "i",
|
||
й: "y",
|
||
к: "k",
|
||
л: "l",
|
||
м: "m",
|
||
н: "n",
|
||
о: "o",
|
||
п: "p",
|
||
р: "r",
|
||
с: "s",
|
||
т: "t",
|
||
у: "u",
|
||
ф: "f",
|
||
х: "h",
|
||
ц: "c",
|
||
ч: "ch",
|
||
ш: "sh",
|
||
щ: "sch",
|
||
ъ: "",
|
||
ы: "y",
|
||
ь: "",
|
||
э: "e",
|
||
ю: "yu",
|
||
я: "ya",
|
||
};
|
||
const safe = String(value || "")
|
||
.toLowerCase()
|
||
.replace(/[а-яё]/g, (char) => map[char] || "")
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
|
||
return safe || fallback;
|
||
}
|
||
|
||
function uniqueKnowledgeValue(values, base) {
|
||
const cleanBase = base || "material";
|
||
if (!values.has(cleanBase)) return cleanBase;
|
||
let index = 2;
|
||
while (values.has(`${cleanBase}-${index}`)) index += 1;
|
||
return `${cleanBase}-${index}`;
|
||
}
|
||
|
||
function createKnowledgeNode(title = "Новый материал", siblings = []) {
|
||
const ids = allKnowledgeIds();
|
||
const siblingSlugs = new Set(siblings.map((node) => node.slug).filter(Boolean));
|
||
const slug = uniqueKnowledgeValue(siblingSlugs, knowledgeSlug(title));
|
||
return {
|
||
id: uniqueKnowledgeValue(ids, slug),
|
||
title,
|
||
slug,
|
||
enabled: true,
|
||
useContent: true,
|
||
seo: {
|
||
title,
|
||
description: "",
|
||
},
|
||
bodyHtml: "<p>Новый материал базы знаний.</p>",
|
||
children: [],
|
||
};
|
||
}
|
||
|
||
function ensureKnowledgeSelection() {
|
||
if (!state.knowledge) return;
|
||
if (isKnowledgeRootSelected()) return;
|
||
const existing = state.knowledgeSelectedId ? findKnowledgeNode(state.knowledgeSelectedId) : null;
|
||
if (existing) return;
|
||
state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID;
|
||
}
|
||
|
||
function selectKnowledgeNode(id) {
|
||
state.knowledgeSelectedId = id;
|
||
updateAdminLayoutState({ knowledgeSelectedId: id });
|
||
renderKnowledgeAll();
|
||
}
|
||
|
||
function persistKnowledgeOpenState() {
|
||
updateAdminLayoutState({ knowledgeOpenIds: [...state.knowledgeOpenIds] });
|
||
}
|
||
|
||
function setKnowledgeNodeOpen(id, open) {
|
||
if (!id) return;
|
||
if (open) state.knowledgeOpenIds.add(id);
|
||
else state.knowledgeOpenIds.delete(id);
|
||
persistKnowledgeOpenState();
|
||
}
|
||
|
||
function addKnowledgeNode(parentId = null) {
|
||
const parent = parentId ? findKnowledgeNode(parentId)?.node : null;
|
||
const siblings = parent ? (parent.children ||= []) : knowledgeTree();
|
||
const node = createKnowledgeNode(parent ? "Новый подраздел" : "Новый раздел", siblings);
|
||
siblings.push(node);
|
||
if (parent) setKnowledgeNodeOpen(parent.id, true);
|
||
state.knowledgeSelectedId = node.id;
|
||
renderKnowledgeAll();
|
||
markDirty("Материал добавлен в дерево базы знаний.");
|
||
}
|
||
|
||
function moveKnowledgeNode(id, delta) {
|
||
const match = findKnowledgeNode(id);
|
||
if (!match) return;
|
||
const nextIndex = match.index + delta;
|
||
if (nextIndex < 0 || nextIndex >= match.siblings.length) return;
|
||
const [node] = match.siblings.splice(match.index, 1);
|
||
match.siblings.splice(nextIndex, 0, node);
|
||
renderKnowledgeAll();
|
||
markDirty("Порядок дерева базы знаний изменён.");
|
||
}
|
||
|
||
async function deleteKnowledgeNode(id) {
|
||
const match = findKnowledgeNode(id);
|
||
if (!match) return;
|
||
const confirmed = await requestDeleteConfirmation({
|
||
title: "Удалить материал?",
|
||
body: `«${match.node.title || match.node.id}» и все вложенные элементы будут удалены из дерева.`,
|
||
});
|
||
if (!confirmed) return;
|
||
match.siblings.splice(match.index, 1);
|
||
if (state.knowledgeSelectedId === id) {
|
||
state.knowledgeSelectedId = match.siblings[Math.min(match.index, match.siblings.length - 1)]?.id || match.parent?.id || KNOWLEDGE_ROOT_ID;
|
||
}
|
||
renderKnowledgeAll();
|
||
markDirty("Материал удалён из дерева базы знаний.");
|
||
}
|
||
|
||
function renderKnowledgeTreeItem(node, depth = 0) {
|
||
const item = document.createElement("div");
|
||
const row = document.createElement("div");
|
||
const select = document.createElement("button");
|
||
const icon = document.createElement("span");
|
||
const copy = document.createElement("span");
|
||
const title = document.createElement("span");
|
||
const meta = document.createElement("span");
|
||
const actions = document.createElement("span");
|
||
const children = Array.isArray(node.children) ? node.children : [];
|
||
const hasChildren = children.length > 0;
|
||
const isOpen = state.knowledgeOpenIds.has(node.id) || node.id === state.knowledgeSelectedId || children.some((child) => child.id === state.knowledgeSelectedId);
|
||
|
||
item.className = "knowledge-tree-item";
|
||
item.style.setProperty("--depth", String(depth));
|
||
row.className = "knowledge-tree-row";
|
||
row.dataset.active = String(node.id === state.knowledgeSelectedId);
|
||
row.dataset.disabled = String(node.enabled === false);
|
||
select.className = "knowledge-tree-select";
|
||
select.type = "button";
|
||
icon.className = "knowledge-tree-icon";
|
||
icon.textContent = hasChildren ? (isOpen ? "▾" : "›") : node.useContent === false ? "G" : "A";
|
||
copy.className = "knowledge-tree-copy";
|
||
title.className = "knowledge-tree-title";
|
||
title.textContent = node.title || node.id;
|
||
meta.className = "knowledge-tree-meta";
|
||
meta.textContent = node.useContent === false ? "группа" : hasChildren ? "страница + ветка" : "статья";
|
||
copy.append(title, meta);
|
||
select.append(icon, copy);
|
||
select.addEventListener("click", () => {
|
||
if (hasChildren) setKnowledgeNodeOpen(node.id, !isOpen);
|
||
selectKnowledgeNode(node.id);
|
||
});
|
||
|
||
actions.className = "knowledge-tree-actions";
|
||
[
|
||
["+", "Добавить дочерний материал", () => addKnowledgeNode(node.id)],
|
||
["↑", "Поднять", () => moveKnowledgeNode(node.id, -1)],
|
||
["↓", "Опустить", () => moveKnowledgeNode(node.id, 1)],
|
||
[FINDER_TRASH_ICON_SVG, "Удалить", () => deleteKnowledgeNode(node.id)],
|
||
].forEach(([symbol, titleText, handler]) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "knowledge-tree-action";
|
||
button.title = titleText;
|
||
button.setAttribute("aria-label", titleText);
|
||
if (titleText === "Удалить") button.innerHTML = symbol;
|
||
else button.textContent = symbol;
|
||
button.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
Promise.resolve(handler()).catch((error) => setStatus(`Ошибка дерева: ${error.message}`));
|
||
});
|
||
actions.append(button);
|
||
});
|
||
|
||
row.append(select, actions);
|
||
item.append(row);
|
||
|
||
if (hasChildren && isOpen) {
|
||
const childList = document.createElement("div");
|
||
childList.className = "knowledge-tree-children";
|
||
children.forEach((child) => childList.append(renderKnowledgeTreeItem(child, depth + 1)));
|
||
item.append(childList);
|
||
}
|
||
|
||
return item;
|
||
}
|
||
|
||
function renderKnowledgeRootItem() {
|
||
const item = document.createElement("div");
|
||
const row = document.createElement("div");
|
||
const select = document.createElement("button");
|
||
const icon = document.createElement("span");
|
||
const copy = document.createElement("span");
|
||
const title = document.createElement("span");
|
||
const meta = document.createElement("span");
|
||
const actions = document.createElement("span");
|
||
const add = document.createElement("button");
|
||
|
||
item.className = "knowledge-tree-item knowledge-root-item";
|
||
item.style.setProperty("--depth", "0");
|
||
row.className = "knowledge-tree-row";
|
||
row.dataset.active = String(isKnowledgeRootSelected());
|
||
row.dataset.disabled = "false";
|
||
select.className = "knowledge-tree-select";
|
||
select.type = "button";
|
||
icon.className = "knowledge-tree-icon";
|
||
icon.textContent = "K";
|
||
copy.className = "knowledge-tree-copy";
|
||
title.className = "knowledge-tree-title";
|
||
title.textContent = knowledgeRootTitle();
|
||
meta.className = "knowledge-tree-meta";
|
||
meta.textContent = "/knowledge/ главная";
|
||
copy.append(title, meta);
|
||
select.append(icon, copy);
|
||
select.addEventListener("click", () => selectKnowledgeNode(KNOWLEDGE_ROOT_ID));
|
||
|
||
actions.className = "knowledge-tree-actions";
|
||
add.type = "button";
|
||
add.className = "knowledge-tree-action";
|
||
add.title = "Добавить раздел в корень";
|
||
add.setAttribute("aria-label", "Добавить раздел в корень");
|
||
add.textContent = "+";
|
||
add.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
addKnowledgeNode(null);
|
||
});
|
||
actions.append(add);
|
||
|
||
row.append(select, actions);
|
||
item.append(row);
|
||
return item;
|
||
}
|
||
|
||
function renderKnowledgeRegions() {
|
||
el.regions.innerHTML = "";
|
||
const group = document.createElement("section");
|
||
const title = document.createElement("h2");
|
||
const root = document.createElement("div");
|
||
|
||
group.className = "region-group knowledge-region";
|
||
title.className = "region-title";
|
||
title.textContent = "База знаний";
|
||
root.className = "knowledge-tree";
|
||
root.append(renderKnowledgeRootItem());
|
||
|
||
if (!knowledgeTree().length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "empty-note knowledge-empty-tree";
|
||
empty.textContent = "Дерево пустое. Нажмите плюс сверху, чтобы добавить первый раздел.";
|
||
root.append(empty);
|
||
} else {
|
||
knowledgeTree().forEach((node) => root.append(renderKnowledgeTreeItem(node)));
|
||
}
|
||
|
||
group.append(title, root);
|
||
el.regions.append(group);
|
||
}
|
||
|
||
function updateKnowledgeNodeField(node, path, value, message = "Материал базы знаний обновлён.") {
|
||
setByPath(node, path, value);
|
||
markDirty(message);
|
||
}
|
||
|
||
function createKnowledgeInput({ node, path, label, kind = "text", wide = false, placeholder = "" }) {
|
||
const field = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kindLabel = document.createElement("span");
|
||
const input = kind === "textarea" ? document.createElement("textarea") : document.createElement("input");
|
||
const pathLabel = document.createElement("span");
|
||
|
||
field.className = `field-control${wide || kind === "textarea" ? " wide" : ""}`;
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = label;
|
||
kindLabel.className = "field-kind";
|
||
kindLabel.textContent = kind;
|
||
input.value = getByPath(node, path) ?? "";
|
||
input.placeholder = placeholder;
|
||
input.dataset.knowledgePath = path;
|
||
if (input.tagName === "INPUT") {
|
||
input.type = kind === "url" ? "url" : "text";
|
||
input.autocomplete = "off";
|
||
}
|
||
input.addEventListener("input", () => {
|
||
updateKnowledgeNodeField(node, path, input.value);
|
||
if (path === "title" || path === "settings.navTitle") renderKnowledgeRegions();
|
||
});
|
||
pathLabel.className = "field-path";
|
||
pathLabel.textContent = path;
|
||
labelRow.append(labelText, kindLabel);
|
||
field.append(labelRow, input, pathLabel);
|
||
return field;
|
||
}
|
||
|
||
function createKnowledgeCheckbox({ node, path, label, checkedDefault = true }) {
|
||
const field = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kindLabel = document.createElement("span");
|
||
const input = document.createElement("input");
|
||
const pathLabel = document.createElement("span");
|
||
|
||
field.className = "field-control boolean-field";
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = label;
|
||
kindLabel.className = "field-kind";
|
||
kindLabel.textContent = "boolean";
|
||
input.type = "checkbox";
|
||
const value = getByPath(node, path);
|
||
input.checked = value == null ? checkedDefault : value !== false;
|
||
input.addEventListener("change", () => {
|
||
updateKnowledgeNodeField(node, path, input.checked);
|
||
renderKnowledgeEditor();
|
||
renderKnowledgeRegions();
|
||
});
|
||
pathLabel.className = "field-path";
|
||
pathLabel.textContent = path;
|
||
labelRow.append(labelText, kindLabel);
|
||
field.append(labelRow, input, pathLabel);
|
||
return field;
|
||
}
|
||
|
||
function knowledgeSelectionNodeInsideEditor(editor, node) {
|
||
if (!editor || !node) return false;
|
||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||
return Boolean(element && editor.contains(element));
|
||
}
|
||
|
||
function saveKnowledgeEditorSelection() {
|
||
const selection = window.getSelection();
|
||
if (!selection?.rangeCount) return;
|
||
const editor = el.knowledgeForm.querySelector("[data-knowledge-richtext]");
|
||
if (!knowledgeSelectionNodeInsideEditor(editor, selection.anchorNode) || !knowledgeSelectionNodeInsideEditor(editor, selection.focusNode)) return;
|
||
state.knowledgeSavedSelection = selection.getRangeAt(0).cloneRange();
|
||
}
|
||
|
||
function restoreKnowledgeEditorSelection(editor) {
|
||
editor.focus();
|
||
let range = state.knowledgeSavedSelection;
|
||
if (!range || !knowledgeSelectionNodeInsideEditor(editor, range.commonAncestorContainer)) {
|
||
range = document.createRange();
|
||
range.selectNodeContents(editor);
|
||
range.collapse(false);
|
||
state.knowledgeSavedSelection = range.cloneRange();
|
||
}
|
||
const selection = window.getSelection();
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
return range;
|
||
}
|
||
|
||
function syncKnowledgeRichText(node, editor) {
|
||
setByPath(node, editor.dataset.knowledgeBodyPath || "bodyHtml", editor.innerHTML.trim());
|
||
markDirty("Текст статьи обновлён.");
|
||
}
|
||
|
||
function execKnowledgeCommand(editor, node, command, value = null) {
|
||
restoreKnowledgeEditorSelection(editor);
|
||
document.execCommand(command, false, value);
|
||
syncKnowledgeRichText(node, editor);
|
||
}
|
||
|
||
function insertKnowledgeHtml(editor, node, html) {
|
||
restoreKnowledgeEditorSelection(editor);
|
||
document.execCommand("insertHTML", false, html);
|
||
saveKnowledgeEditorSelection();
|
||
syncKnowledgeRichText(node, editor);
|
||
}
|
||
|
||
function escapeKnowledgeShortcodeAttr(value) {
|
||
return String(value ?? "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.replaceAll("&", "&")
|
||
.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, `<img src="${escapeHtml(safeUrl)}" alt="" loading="lazy">`);
|
||
},
|
||
}).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`));
|
||
}
|
||
|
||
function renderKnowledgeToolbar(editor, node) {
|
||
const toolbar = document.createElement("div");
|
||
toolbar.className = "knowledge-rich-toolbar";
|
||
const buttons = [
|
||
["P", "Абзац", () => execKnowledgeCommand(editor, node, "formatBlock", "P")],
|
||
["H2", "Заголовок H2", () => execKnowledgeCommand(editor, node, "formatBlock", "H2")],
|
||
["H3", "Заголовок H3", () => execKnowledgeCommand(editor, node, "formatBlock", "H3")],
|
||
["B", "Жирный", () => execKnowledgeCommand(editor, node, "bold")],
|
||
["I", "Курсив", () => execKnowledgeCommand(editor, node, "italic")],
|
||
["UL", "Список", () => execKnowledgeCommand(editor, node, "insertUnorderedList")],
|
||
["OL", "Нумерованный список", () => execKnowledgeCommand(editor, node, "insertOrderedList")],
|
||
["Link", "Ссылка", () => {
|
||
const href = window.prompt("Адрес ссылки", "/knowledge/");
|
||
if (!href) return;
|
||
execKnowledgeCommand(editor, node, "createLink", href);
|
||
}],
|
||
["Button", "Кнопка", async () => {
|
||
saveKnowledgeEditorSelection();
|
||
const values = await requestKnowledgeButtonInput();
|
||
if (!values) return;
|
||
const targetInput = values.targetInput;
|
||
const landingTarget = findLandingTargetByInput(targetInput);
|
||
const target = landingTarget ? landingTarget.target : targetInput;
|
||
const blockId = landingTarget?.id || "";
|
||
insertKnowledgeHtml(
|
||
editor,
|
||
node,
|
||
`<p>${escapeHtml(knowledgeButtonShortcode({ name: values.name, link: values.link, target, blockId }))}</p>`,
|
||
);
|
||
}],
|
||
["Image", "Изображение", () => openKnowledgeMediaPicker(editor, node)],
|
||
];
|
||
|
||
buttons.forEach(([label, title, action]) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = label;
|
||
button.title = title;
|
||
button.addEventListener("mousedown", (event) => event.preventDefault());
|
||
button.addEventListener("click", action);
|
||
toolbar.append(button);
|
||
});
|
||
|
||
return toolbar;
|
||
}
|
||
|
||
function renderKnowledgeEditor() {
|
||
const isRoot = isKnowledgeRootSelected();
|
||
const node = selectedKnowledgeNode();
|
||
el.empty.classList.add("hidden");
|
||
el.form.classList.add("hidden");
|
||
el.knowledgeForm.classList.remove("hidden");
|
||
el.knowledgeForm.innerHTML = "";
|
||
|
||
if (!state.knowledge) {
|
||
el.knowledgeForm.innerHTML = '<div class="empty-note">Загружаем базу знаний...</div>';
|
||
return;
|
||
}
|
||
|
||
if (!node) {
|
||
const empty = document.createElement("section");
|
||
empty.className = "content-panel";
|
||
empty.innerHTML = "<h3>Дерево базы знаний пустое</h3><p class=\"group-desc\">Нажмите плюс в левом верхнем углу, чтобы создать первый раздел.</p>";
|
||
el.knowledgeForm.append(empty);
|
||
return;
|
||
}
|
||
|
||
state.knowledge.settings ||= {};
|
||
const bodyPath = isRoot ? "introHtml" : "bodyHtml";
|
||
const match = isRoot ? null : findKnowledgeNode(node.id);
|
||
const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний";
|
||
el.selectedRegion.textContent = isRoot
|
||
? `NODE.DC / ${knowledgeRootTitle()} / /knowledge/`
|
||
: `NODE.DC / ${state.knowledge.title || "База знаний"} / ${parentTitle}`;
|
||
el.selectedTitle.textContent = isRoot ? knowledgeRootTitle() : node.title || "Материал";
|
||
|
||
const metaPanel = document.createElement("section");
|
||
const metaHead = document.createElement("div");
|
||
const metaTitle = document.createElement("h3");
|
||
const metaKicker = document.createElement("div");
|
||
const grid = document.createElement("div");
|
||
metaPanel.className = "content-panel";
|
||
metaHead.className = "panel-head";
|
||
metaKicker.className = "section-kicker";
|
||
metaKicker.textContent = isRoot ? "Корневая страница" : "Узел дерева";
|
||
metaTitle.textContent = isRoot ? "Настройки /knowledge/" : "Параметры материала";
|
||
metaHead.append(Object.assign(document.createElement("div"), { innerHTML: `${metaKicker.outerHTML}<h3>${metaTitle.textContent}</h3>` }));
|
||
grid.className = "group-grid";
|
||
if (isRoot) {
|
||
grid.append(
|
||
createKnowledgeInput({ node, path: "title", label: "H1 / название в sidebar", wide: false }),
|
||
createKnowledgeInput({ node, path: "settings.navTitle", label: "Название в шапке", wide: false }),
|
||
createKnowledgeInput({ node, path: "settings.searchPlaceholder", label: "Placeholder поиска", wide: false }),
|
||
createKnowledgeInput({ node, path: "settings.homeHref", label: "Ссылка логотипа", kind: "url", wide: false }),
|
||
createKnowledgeInput({ node, path: "settings.themeDefault", label: "Тема по умолчанию", wide: false }),
|
||
createKnowledgeCheckbox({ node, path: "settings.downloadEnabled", label: "Показывать Download", checkedDefault: false }),
|
||
);
|
||
} else {
|
||
grid.append(
|
||
createKnowledgeInput({ node, path: "title", label: "Название", wide: false }),
|
||
createKnowledgeInput({ node, path: "slug", label: "Slug", wide: false }),
|
||
createKnowledgeCheckbox({ node, path: "enabled", label: "Публиковать" }),
|
||
createKnowledgeCheckbox({ node, path: "useContent", label: "Использовать как информационную страницу" }),
|
||
);
|
||
}
|
||
metaPanel.append(metaHead, grid);
|
||
|
||
const seoPanel = document.createElement("section");
|
||
const seoHead = document.createElement("div");
|
||
const seoGrid = document.createElement("div");
|
||
seoPanel.className = "content-panel";
|
||
seoHead.className = "panel-head";
|
||
seoHead.innerHTML = `<div><div class="section-kicker">SEO</div><h3>${isRoot ? "Сниппет раздела" : "Сниппет страницы"}</h3></div><span class="panel-note">Поля попадут в title, description, Open Graph и sitemap.</span>`;
|
||
seoGrid.className = "group-grid";
|
||
if (isRoot) {
|
||
seoGrid.append(
|
||
createKnowledgeInput({ node, path: "settings.description", label: "SEO description / описание", kind: "textarea", wide: true }),
|
||
);
|
||
} else {
|
||
node.seo ||= {};
|
||
seoGrid.append(
|
||
createKnowledgeInput({ node, path: "seo.title", label: "SEO title", wide: true }),
|
||
createKnowledgeInput({ node, path: "seo.description", label: "SEO description", kind: "textarea", wide: true }),
|
||
);
|
||
}
|
||
seoPanel.append(seoHead, seoGrid);
|
||
|
||
const contentPanel = document.createElement("section");
|
||
const contentHead = document.createElement("div");
|
||
const editor = document.createElement("div");
|
||
contentPanel.className = "content-panel knowledge-rich-panel";
|
||
contentHead.className = "panel-head";
|
||
contentHead.innerHTML = `<div><div class="section-kicker">Контент</div><h3>${isRoot ? "Текст главной страницы" : "Текст статьи"}</h3></div><span class="panel-note">H2/H3 формируют правый список “На этой странице”.</span>`;
|
||
editor.className = "knowledge-rich-editor";
|
||
editor.contentEditable = isRoot || node.useContent !== false ? "true" : "false";
|
||
editor.dataset.knowledgeRichtext = "true";
|
||
editor.dataset.knowledgeBodyPath = bodyPath;
|
||
editor.innerHTML = getByPath(node, bodyPath) || "<p></p>";
|
||
editor.addEventListener("focusout", () => saveKnowledgeEditorSelection());
|
||
editor.addEventListener("keyup", () => saveKnowledgeEditorSelection());
|
||
editor.addEventListener("mouseup", () => saveKnowledgeEditorSelection());
|
||
editor.addEventListener("input", () => syncKnowledgeRichText(node, editor));
|
||
contentPanel.append(contentHead, renderKnowledgeToolbar(editor, node), editor);
|
||
if (!isRoot && node.useContent === false) {
|
||
const disabledNote = document.createElement("p");
|
||
disabledNote.className = "group-desc";
|
||
disabledNote.textContent = "Контент сохранится в модели, но публичная страница для этого узла не будет генерироваться, пока флаг выключен.";
|
||
contentPanel.append(disabledNote);
|
||
}
|
||
|
||
const jsonDetails = document.createElement("details");
|
||
jsonDetails.className = "json-details knowledge-json";
|
||
jsonDetails.innerHTML = `<summary>${isRoot ? "JSON всей базы знаний" : "JSON выбранного материала"}</summary>`;
|
||
const jsonField = document.createElement("label");
|
||
const json = document.createElement("textarea");
|
||
jsonField.className = "json-field";
|
||
json.value = JSON.stringify(node, null, 2);
|
||
json.addEventListener("blur", () => {
|
||
try {
|
||
const parsed = JSON.parse(json.value);
|
||
if (isRoot) {
|
||
state.knowledge = parsed;
|
||
state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID;
|
||
} else {
|
||
const match = findKnowledgeNode(node.id);
|
||
if (!match) return;
|
||
match.siblings[match.index] = parsed;
|
||
state.knowledgeSelectedId = parsed.id || node.id;
|
||
}
|
||
renderKnowledgeAll();
|
||
markDirty(isRoot ? "JSON базы знаний применён локально." : "JSON материала применён локально.");
|
||
} catch (error) {
|
||
setStatus(`Ошибка JSON ${isRoot ? "базы знаний" : "материала"}: ${error.message}`);
|
||
}
|
||
});
|
||
jsonField.innerHTML = "<span>Техническое представление</span>";
|
||
jsonField.append(json);
|
||
jsonDetails.append(jsonField);
|
||
|
||
el.knowledgeForm.append(metaPanel, seoPanel, contentPanel, jsonDetails);
|
||
}
|
||
|
||
function renderKnowledgeAll() {
|
||
updateAdminModeSurface();
|
||
ensureKnowledgeSelection();
|
||
renderKnowledgeRegions();
|
||
renderKnowledgeEditor();
|
||
}
|
||
|
||
async function loadKnowledge() {
|
||
const response = await fetch("/api/knowledge");
|
||
if (!response.ok) throw new Error(await response.text());
|
||
state.knowledge = await response.json();
|
||
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
|
||
ensureKnowledgeSelection();
|
||
renderAll();
|
||
setDirty(false);
|
||
setStatus("Модель knowledge.json загружена.");
|
||
}
|
||
|
||
async function persistKnowledge() {
|
||
const response = await fetch("/api/knowledge", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(state.knowledge),
|
||
});
|
||
|
||
if (!response.ok) throw new Error(await response.text());
|
||
}
|
||
|
||
async function saveKnowledge() {
|
||
await persistKnowledge();
|
||
setDirty(false);
|
||
setStatus("База знаний сохранена в content/knowledge.json.");
|
||
}
|
||
|
||
async function renderKnowledge() {
|
||
await persistKnowledge();
|
||
const response = await fetch("/api/render/knowledge", { method: "POST" });
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "Render failed");
|
||
}
|
||
|
||
setDirty(false);
|
||
setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана.");
|
||
}
|
||
|
||
async function loadPage() {
|
||
const [pageResponse, templatesResponse] = await Promise.all([
|
||
fetch("/api/page/home"),
|
||
fetch("/api/block-templates/home"),
|
||
]);
|
||
|
||
if (!pageResponse.ok) throw new Error(await pageResponse.text());
|
||
if (!templatesResponse.ok) throw new Error(await templatesResponse.text());
|
||
|
||
state.page = await pageResponse.json();
|
||
state.templates = await templatesResponse.json();
|
||
ensureStaticElements();
|
||
ensureSectionsRegion();
|
||
ensureSeoModel();
|
||
state.selected = { kind: "static" };
|
||
renderAll();
|
||
setDirty(false);
|
||
setStatus("Модель home.json загружена.");
|
||
|
||
if (state.mode === "knowledge" && !state.knowledge) {
|
||
await loadKnowledge();
|
||
}
|
||
}
|
||
|
||
async function persistPage() {
|
||
if (activeBlock()) syncBlockFromFields();
|
||
assertUniqueLandingTargets();
|
||
|
||
const response = await fetch("/api/page/home", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(state.page),
|
||
});
|
||
|
||
if (!response.ok) throw new Error(await response.text());
|
||
}
|
||
|
||
async function savePage() {
|
||
await persistPage();
|
||
setDirty(false);
|
||
setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html».");
|
||
}
|
||
|
||
async function renderHome() {
|
||
await persistPage();
|
||
const response = await fetch("/api/render/home", { method: "POST" });
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "Render failed");
|
||
}
|
||
|
||
setDirty(false);
|
||
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
||
}
|
||
|
||
function moveSelected(delta) {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
const index = state.selected?.blockIndex;
|
||
if (!region || index == null) return;
|
||
|
||
const nextIndex = index + delta;
|
||
if (nextIndex < 0 || nextIndex >= region.blocks.length) return;
|
||
|
||
const [block] = region.blocks.splice(index, 1);
|
||
region.blocks.splice(nextIndex, 0, block);
|
||
state.selected.blockIndex = nextIndex;
|
||
renderAll();
|
||
markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||
}
|
||
|
||
function duplicateSelected() {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
const block = activeBlock();
|
||
if (!region || !block) return;
|
||
|
||
const duplicate = clone(block);
|
||
duplicate.id = uniqueId(block.id);
|
||
duplicate.adminLabel = `${block.adminLabel || block.id} — копия`;
|
||
if (duplicate.anchor) {
|
||
duplicate.anchor = uniqueLandingTarget(duplicate.anchor, duplicate);
|
||
}
|
||
duplicate.scopeIds = true;
|
||
region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate);
|
||
state.selected.blockIndex += 1;
|
||
renderAll();
|
||
markDirty(`Блок «${duplicate.adminLabel || duplicate.id}» продублирован локально.`);
|
||
}
|
||
|
||
function copySelected() {
|
||
if (isStaticSelection()) return;
|
||
const block = activeBlock();
|
||
if (!block) return;
|
||
|
||
state.clipboard = clone(block);
|
||
setStatus(`Скопирован блок: ${block.adminLabel || block.id}`);
|
||
}
|
||
|
||
function pasteAfterSelected() {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
if (!region || !state.clipboard) return;
|
||
|
||
const pasted = clone(state.clipboard);
|
||
pasted.id = uniqueId(pasted.id);
|
||
pasted.adminLabel = `${pasted.adminLabel || pasted.id} — вставка`;
|
||
if (pasted.anchor) {
|
||
pasted.anchor = uniqueLandingTarget(pasted.anchor, pasted);
|
||
}
|
||
pasted.scopeIds = true;
|
||
region.blocks.splice(state.selected.blockIndex + 1, 0, pasted);
|
||
state.selected.blockIndex += 1;
|
||
renderAll();
|
||
markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`);
|
||
}
|
||
|
||
async function deleteSelected() {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
if (!region || state.selected.blockIndex == null) return;
|
||
|
||
const confirmed = await requestDeleteConfirmation();
|
||
if (!confirmed) return;
|
||
|
||
region.blocks.splice(state.selected.blockIndex, 1);
|
||
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
|
||
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
|
||
renderAll();
|
||
markDirty("Блок удалён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||
}
|
||
|
||
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.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", () => {
|
||
const action = state.mode === "knowledge" ? loadKnowledge : loadPage;
|
||
action().catch((error) => setStatus(error.message));
|
||
});
|
||
el.save.addEventListener("click", () => {
|
||
const action = state.mode === "knowledge" ? saveKnowledge : savePage;
|
||
action().catch((error) => setStatus(error.message));
|
||
});
|
||
el.render.addEventListener("click", () => {
|
||
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
|
||
action().catch((error) => setStatus(error.message));
|
||
});
|
||
el.moveUp.addEventListener("click", () => moveSelected(-1));
|
||
el.moveDown.addEventListener("click", () => moveSelected(1));
|
||
el.duplicate.addEventListener("click", duplicateSelected);
|
||
el.copy.addEventListener("click", copySelected);
|
||
el.pasteAfter.addEventListener("click", pasteAfterSelected);
|
||
el.deleteBlock.addEventListener("click", () => deleteSelected().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)));
|
||
|
||
window.addEventListener("error", (event) => {
|
||
recoverEditorSurface();
|
||
setStatus(`Ошибка JS: ${event.message}`);
|
||
});
|
||
|
||
window.addEventListener("unhandledrejection", (event) => {
|
||
recoverEditorSurface();
|
||
const reason = event.reason instanceof Error ? event.reason.message : String(event.reason);
|
||
setStatus(`Ошибка JS: ${reason}`);
|
||
});
|
||
|
||
loadPage().catch((error) => setStatus(error.message));
|