Кнопка в статье
+Ссылка ведёт на лендинг или внешний адрес. Target открывает нужный блок лендинга.
+diff --git a/_astro/InstrumentSans-Bold.DWGdGw8L.woff2 b/_astro/InstrumentSans-Bold.DWGdGw8L.woff2 new file mode 100644 index 0000000..e4e387f Binary files /dev/null and b/_astro/InstrumentSans-Bold.DWGdGw8L.woff2 differ diff --git a/_astro/InstrumentSans-BoldItalic.BOjDmDe7.woff2 b/_astro/InstrumentSans-BoldItalic.BOjDmDe7.woff2 new file mode 100644 index 0000000..1d8b388 Binary files /dev/null and b/_astro/InstrumentSans-BoldItalic.BOjDmDe7.woff2 differ diff --git a/_astro/InstrumentSans-Italic.FeNa2EJX.woff2 b/_astro/InstrumentSans-Italic.FeNa2EJX.woff2 new file mode 100644 index 0000000..ef2e884 Binary files /dev/null and b/_astro/InstrumentSans-Italic.FeNa2EJX.woff2 differ diff --git a/_astro/InstrumentSans-Medium.B2xOBd5A.woff2 b/_astro/InstrumentSans-Medium.B2xOBd5A.woff2 new file mode 100644 index 0000000..026309a Binary files /dev/null and b/_astro/InstrumentSans-Medium.B2xOBd5A.woff2 differ diff --git a/_astro/InstrumentSans-MediumItalic.c03vUl6A.woff2 b/_astro/InstrumentSans-MediumItalic.c03vUl6A.woff2 new file mode 100644 index 0000000..b5b16ce Binary files /dev/null and b/_astro/InstrumentSans-MediumItalic.c03vUl6A.woff2 differ diff --git a/_astro/InstrumentSans-Regular.JnvgzjHK.woff2 b/_astro/InstrumentSans-Regular.JnvgzjHK.woff2 new file mode 100644 index 0000000..fd72c25 Binary files /dev/null and b/_astro/InstrumentSans-Regular.JnvgzjHK.woff2 differ diff --git a/_astro/InstrumentSans-SemiBold.wfsLgfkq.woff2 b/_astro/InstrumentSans-SemiBold.wfsLgfkq.woff2 new file mode 100644 index 0000000..8073ca4 Binary files /dev/null and b/_astro/InstrumentSans-SemiBold.wfsLgfkq.woff2 differ diff --git a/_astro/InstrumentSans-SemiBoldItalic.DRnKVj3E.woff2 b/_astro/InstrumentSans-SemiBoldItalic.DRnKVj3E.woff2 new file mode 100644 index 0000000..b3ba828 Binary files /dev/null and b/_astro/InstrumentSans-SemiBoldItalic.DRnKVj3E.woff2 differ diff --git a/admin/admin.css b/admin/admin.css index e152db4..f4baa1e 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -802,6 +802,15 @@ body[data-admin-mode="seo"] .json-details { display: none; } +body[data-admin-mode="knowledge"] #block-form, +body[data-admin-mode="knowledge"] #empty-state { + display: none !important; +} + +body:not([data-admin-mode="knowledge"]) #knowledge-form { + display: none !important; +} + .hidden { display: none !important; } @@ -1669,6 +1678,221 @@ select { line-height: 1.45; } +.knowledge-editor { + display: grid; + min-height: 0; + overflow: auto; + gap: 1rem; + padding: 0 0.2rem 5rem; + scrollbar-color: rgba(255, 255, 255, 0.25) transparent; +} + +.knowledge-tree { + display: grid; + gap: 0.28rem; + padding-inline: 0.35rem; +} + +.knowledge-tree-item, +.knowledge-tree-children { + display: grid; + gap: 0.22rem; +} + +.knowledge-tree-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-height: 3.1rem; + align-items: center; + gap: 0.28rem; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.035); + padding: 0.24rem 0.34rem 0.24rem calc(0.34rem + (var(--depth, 0) * 0.82rem)); + opacity: 0.86; +} + +.knowledge-tree-row[data-active="true"] { + background: rgba(255, 255, 255, 0.14); + opacity: 1; +} + +.knowledge-tree-row[data-disabled="true"] { + opacity: 0.42; +} + +.knowledge-tree-select { + display: grid; + grid-template-columns: 2.2rem minmax(0, 1fr); + min-width: 0; + min-height: 2.55rem; + align-items: center; + gap: 0.5rem; + border-radius: var(--launcher-radius-circle); + background: transparent; + color: var(--text-secondary); + padding: 0; + text-align: left; +} + +.knowledge-tree-icon { + display: grid; + width: 2.2rem; + height: 2.2rem; + place-items: center; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.055); + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 850; +} + +.knowledge-tree-copy { + display: grid; + min-width: 0; + gap: 0.1rem; +} + +.knowledge-tree-title, +.knowledge-tree-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.knowledge-tree-title { + color: var(--text-primary); + font-size: 0.82rem; + font-weight: 850; +} + +.knowledge-tree-meta { + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 720; +} + +.knowledge-tree-actions { + display: inline-flex; + gap: 0.18rem; + opacity: 0; + transition: opacity 160ms ease; +} + +.knowledge-tree-row:hover .knowledge-tree-actions, +.knowledge-tree-row[data-active="true"] .knowledge-tree-actions { + opacity: 1; +} + +.knowledge-tree-action { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.07); + color: var(--text-secondary); + padding: 0; + font-size: 0.82rem; +} + +.knowledge-tree-action:hover { + background: rgba(255, 255, 255, 0.14); + color: var(--text-primary); +} + +.knowledge-tree-action .trash-can-icon { + width: 0.92rem; + height: 0.92rem; +} + +.knowledge-empty-tree { + margin-inline: calc(var(--admin-nav-pad) + 0.35rem); +} + +.knowledge-rich-panel { + gap: 0.8rem; +} + +.knowledge-rich-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.knowledge-rich-toolbar button { + min-height: 2.15rem; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.075); + color: var(--text-secondary); + padding: 0 0.72rem; + font-size: 0.72rem; + font-weight: 850; +} + +.knowledge-rich-toolbar button:hover { + background: rgba(255, 255, 255, 0.14); + color: var(--text-primary); +} + +.knowledge-rich-editor { + min-height: 28rem; + overflow: auto; + border-radius: var(--launcher-radius-card); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + padding: 1.1rem; + font-size: 1rem; + line-height: 1.55; + outline: none; +} + +.knowledge-rich-editor:focus { + background: rgba(255, 255, 255, 0.06); +} + +.knowledge-rich-editor h2, +.knowledge-rich-editor h3, +.knowledge-rich-editor strong { + color: var(--text-primary); +} + +.knowledge-rich-editor h2 { + margin: 1.4rem 0 0.6rem; + font-size: 1.85rem; + line-height: 1.1; +} + +.knowledge-rich-editor h3 { + margin: 1.2rem 0 0.55rem; + font-size: 1.25rem; +} + +.knowledge-rich-editor p { + margin: 0 0 1rem; +} + +.knowledge-rich-editor img { + display: block; + max-width: 100%; + height: auto; + border-radius: 0.65rem; + margin: 1rem 0; +} + +.knowledge-rich-editor .knowledge-button { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); + padding: 0 1.25rem; + font-size: 0.88rem; + font-weight: 850; + text-decoration: none; +} + .status { min-height: 1.25rem; margin: 0.8rem 0.35rem 0; @@ -2469,18 +2693,57 @@ select { width: min(28rem, calc(100vw - 2.8rem)); } +.knowledge-button-modal-layer { + z-index: 300; +} + +.knowledge-button-modal { + width: min(34rem, calc(100vw - 2.8rem)); +} + .media-action-head { padding-right: 1.25rem; } +.knowledge-button-head { + padding-right: 4.5rem; +} + .media-action-body { padding: 0 1.25rem 1.25rem; } +.knowledge-button-body { + display: grid; + gap: 0.9rem; + padding: 0 1.25rem 1.25rem; +} + +.knowledge-button-hint, +.knowledge-button-error { + margin: 0; + font-size: 0.78rem; + font-weight: 650; + line-height: 1.4; +} + +.knowledge-button-hint { + color: var(--text-muted); +} + +.knowledge-button-error { + min-height: 1.1rem; + color: rgba(255, 123, 108, 0.95); +} + .media-action-foot { justify-content: flex-end; } +.knowledge-button-foot { + justify-content: flex-end; +} + .media-modal-foot { justify-content: space-between; } diff --git a/admin/admin.js b/admin/admin.js index 8c41ab7..f894c0d 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -1,4 +1,5 @@ const ADMIN_LAYOUT_STORAGE_KEY = "nodedc-admin-layout-v1"; +const KNOWLEDGE_ROOT_ID = "__knowledge-root__"; function readAdminLayoutState() { try { @@ -35,12 +36,17 @@ function readLastMediaDirectory() { } function readAdminMode() { - return readAdminLayoutState().mode === "seo" ? "seo" : "content"; + 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, @@ -52,6 +58,7 @@ const state = { filePickerResetTimer: null, deleteConfirmResolve: null, mediaActionResolve: null, + knowledgeButtonResolve: null, collectionOpenState: readCollectionOpenState(), mediaLibrary: { columns: [], @@ -140,6 +147,7 @@ const BLOCK_SEO_FIELDS = [ 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"), @@ -155,12 +163,14 @@ const el = { 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"), @@ -210,6 +220,15 @@ const el = { 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 = { @@ -223,7 +242,7 @@ const STATIC_FALLBACK = { logoAlt: "главная", brandLabel: "NODE.DC", links: [ - { enabled: true, label: "О продукте", href: "", target: "index.html#features", linkMode: "target", linkTarget: "" }, + { 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, @@ -233,7 +252,7 @@ const STATIC_FALLBACK = { linkMode: "href", linkTarget: "_blank", }, - { enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-wrap", linkMode: "target", linkTarget: "" }, + { enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-intro", linkMode: "target", linkTarget: "" }, ], }, notifications: [ @@ -496,6 +515,7 @@ function setFilePickerActive(active) { function recoverEditorSurface() { if (!activeBlock()) return; + el.knowledgeForm.classList.add("hidden"); el.empty.classList.add("hidden"); el.form.classList.remove("hidden"); } @@ -513,6 +533,15 @@ 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); } @@ -565,9 +594,14 @@ function ensureSeoModel() { } function setAdminMode(nextMode) { - const mode = nextMode === "seo" ? "seo" : "content"; + 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 { @@ -577,19 +611,37 @@ function setAdminMode(nextMode) { 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); + if (el.modeContent) el.modeContent.dataset.active = String(!isSeo && !isKnowledge); if (el.modeSeo) el.modeSeo.dataset.active = String(isSeo); - if (el.sidebarTitle) el.sidebarTitle.textContent = isSeo ? "SEO" : "Администрирование"; - if (el.workspaceRole) el.workspaceRole.textContent = isSeo ? "SEO-режим сайта" : "Администратор сайта"; - if (el.editorHeading) el.editorHeading.textContent = isSeo ? "SEO сайта" : "Настройки сайта"; - if (el.panelKicker) el.panelKicker.textContent = isSeo ? "SEO" : "Контент"; - if (el.panelTitle) el.panelTitle.textContent = 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 = isSeo - ? "Поля готовят страницу и секции к семантической настройке и дальнейшей автоматизации." - : "Поля сгруппированы по смысловым подблокам верстки."; + 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"; } } @@ -815,6 +867,127 @@ 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; @@ -1586,6 +1759,9 @@ function addBlockFromTemplate(templateKey) { 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, @@ -1606,6 +1782,7 @@ function setStructuralControlsDisabled(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 = ""; @@ -1619,6 +1796,7 @@ function renderEditorError(error) { function renderEditor() { updateAdminModeSurface(); + el.knowledgeForm.classList.add("hidden"); const region = activeRegion(); const block = activeBlock(); @@ -1630,6 +1808,7 @@ function renderEditor() { el.form.classList.add("hidden"); el.selectedRegion.textContent = "NODE.DC / Выберите блок"; el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой"; + renderLandingTargetOptions(); return; } @@ -1645,6 +1824,9 @@ function renderEditor() { 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; @@ -1667,6 +1849,10 @@ function renderEditor() { function renderAll() { try { + if (state.mode === "knowledge") { + renderKnowledgeAll(); + return; + } renderRegions(); renderEditor(); } catch (error) { @@ -1684,8 +1870,14 @@ function syncBlockFromFields() { if (!isStaticSelection()) { block.id = el.id.value.trim(); - block.anchor = el.anchor.value.trim() || null; + 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); @@ -2932,7 +3124,8 @@ async function toggleMediaTrash() { } async function openMediaLibrary(context) { - const currentValuePath = pathFromUrl(context.hiddenInput.value || ""); + 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); @@ -2940,7 +3133,7 @@ async function openMediaLibrary(context) { state.mediaLibrary.context = context; state.mediaLibrary.trashMode = false; state.mediaLibrary.previousPath = ""; - state.mediaLibrary.selectedUrl = context.hiddenInput.value || ""; + state.mediaLibrary.selectedUrl = currentValue; state.mediaLibrary.selectedEntry = null; state.mediaLibrary.selectedPath = startPath; state.mediaLibrary.query = ""; @@ -2954,7 +3147,7 @@ async function openMediaLibrary(context) { refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); try { - await loadMediaColumns(startPath, { selectedUrl: context.hiddenInput.value || "" }); + await loadMediaColumns(startPath, { selectedUrl: currentValue }); } catch (error) { setStatus(`Ошибка медиатеки: ${error.message}`); renderMediaLibrary(); @@ -2982,12 +3175,16 @@ function chooseSelectedMediaFile() { return; } - updateMediaFieldValue({ - ...context, - value: file.url, - }); + 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))); - context.setSource("file"); closeMediaLibrary(); markDirty(`Выбран файл: ${file.url}. Нажмите «Сохранить модель», чтобы записать путь.`); } @@ -4081,6 +4278,808 @@ function syncBlockFromJson() { } } +function knowledgeTree() { + if (!state.knowledge) return []; + if (!Array.isArray(state.knowledge.tree)) state.knowledge.tree = []; + return state.knowledge.tree; +} + +function knowledgeWalk(nodes = knowledgeTree(), parent = null, callback = () => {}) { + nodes.forEach((node, index) => { + callback(node, parent, nodes, index); + knowledgeWalk(Array.isArray(node.children) ? node.children : [], node, callback); + }); +} + +function allKnowledgeIds() { + const ids = new Set(); + knowledgeWalk(knowledgeTree(), null, (node) => { + if (node.id) ids.add(node.id); + }); + return ids; +} + +function findKnowledgeNode(id) { + let found = null; + let foundParent = null; + let foundSiblings = null; + let foundIndex = -1; + + knowledgeWalk(knowledgeTree(), null, (node, parent, siblings, index) => { + if (found || node.id !== id) return; + found = node; + foundParent = parent; + foundSiblings = siblings; + foundIndex = index; + }); + + return found ? { node: found, parent: foundParent, siblings: foundSiblings, index: foundIndex } : null; +} + +function isKnowledgeRootId(id) { + return id === KNOWLEDGE_ROOT_ID; +} + +function isKnowledgeRootSelected() { + return isKnowledgeRootId(state.knowledgeSelectedId); +} + +function knowledgeRootTitle() { + return state.knowledge?.title || state.knowledge?.settings?.navTitle || "Knowledge"; +} + +function selectedKnowledgeNode() { + if (isKnowledgeRootSelected()) return state.knowledge; + return findKnowledgeNode(state.knowledgeSelectedId)?.node || null; +} + +function knowledgeSlug(value, fallback = "material") { + const map = { + а: "a", + б: "b", + в: "v", + г: "g", + д: "d", + е: "e", + ё: "e", + ж: "zh", + з: "z", + и: "i", + й: "y", + к: "k", + л: "l", + м: "m", + н: "n", + о: "o", + п: "p", + р: "r", + с: "s", + т: "t", + у: "u", + ф: "f", + х: "h", + ц: "c", + ч: "ch", + ш: "sh", + щ: "sch", + ъ: "", + ы: "y", + ь: "", + э: "e", + ю: "yu", + я: "ya", + }; + const safe = String(value || "") + .toLowerCase() + .replace(/[а-яё]/g, (char) => map[char] || "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return safe || fallback; +} + +function uniqueKnowledgeValue(values, base) { + const cleanBase = base || "material"; + if (!values.has(cleanBase)) return cleanBase; + let index = 2; + while (values.has(`${cleanBase}-${index}`)) index += 1; + return `${cleanBase}-${index}`; +} + +function createKnowledgeNode(title = "Новый материал", siblings = []) { + const ids = allKnowledgeIds(); + const siblingSlugs = new Set(siblings.map((node) => node.slug).filter(Boolean)); + const slug = uniqueKnowledgeValue(siblingSlugs, knowledgeSlug(title)); + return { + id: uniqueKnowledgeValue(ids, slug), + title, + slug, + enabled: true, + useContent: true, + seo: { + title, + description: "", + }, + bodyHtml: "
Новый материал базы знаний.
", + children: [], + }; +} + +function ensureKnowledgeSelection() { + if (!state.knowledge) return; + if (isKnowledgeRootSelected()) return; + const existing = state.knowledgeSelectedId ? findKnowledgeNode(state.knowledgeSelectedId) : null; + if (existing) return; + state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID; +} + +function selectKnowledgeNode(id) { + state.knowledgeSelectedId = id; + updateAdminLayoutState({ knowledgeSelectedId: id }); + renderKnowledgeAll(); +} + +function persistKnowledgeOpenState() { + updateAdminLayoutState({ knowledgeOpenIds: [...state.knowledgeOpenIds] }); +} + +function setKnowledgeNodeOpen(id, open) { + if (!id) return; + if (open) state.knowledgeOpenIds.add(id); + else state.knowledgeOpenIds.delete(id); + persistKnowledgeOpenState(); +} + +function addKnowledgeNode(parentId = null) { + const parent = parentId ? findKnowledgeNode(parentId)?.node : null; + const siblings = parent ? (parent.children ||= []) : knowledgeTree(); + const node = createKnowledgeNode(parent ? "Новый подраздел" : "Новый раздел", siblings); + siblings.push(node); + if (parent) setKnowledgeNodeOpen(parent.id, true); + state.knowledgeSelectedId = node.id; + renderKnowledgeAll(); + markDirty("Материал добавлен в дерево базы знаний."); +} + +function moveKnowledgeNode(id, delta) { + const match = findKnowledgeNode(id); + if (!match) return; + const nextIndex = match.index + delta; + if (nextIndex < 0 || nextIndex >= match.siblings.length) return; + const [node] = match.siblings.splice(match.index, 1); + match.siblings.splice(nextIndex, 0, node); + renderKnowledgeAll(); + markDirty("Порядок дерева базы знаний изменён."); +} + +async function deleteKnowledgeNode(id) { + const match = findKnowledgeNode(id); + if (!match) return; + const confirmed = await requestDeleteConfirmation({ + title: "Удалить материал?", + body: `«${match.node.title || match.node.id}» и все вложенные элементы будут удалены из дерева.`, + }); + if (!confirmed) return; + match.siblings.splice(match.index, 1); + if (state.knowledgeSelectedId === id) { + state.knowledgeSelectedId = match.siblings[Math.min(match.index, match.siblings.length - 1)]?.id || match.parent?.id || KNOWLEDGE_ROOT_ID; + } + renderKnowledgeAll(); + markDirty("Материал удалён из дерева базы знаний."); +} + +function renderKnowledgeTreeItem(node, depth = 0) { + const item = document.createElement("div"); + const row = document.createElement("div"); + const select = document.createElement("button"); + const icon = document.createElement("span"); + const copy = document.createElement("span"); + const title = document.createElement("span"); + const meta = document.createElement("span"); + const actions = document.createElement("span"); + const children = Array.isArray(node.children) ? node.children : []; + const hasChildren = children.length > 0; + const isOpen = state.knowledgeOpenIds.has(node.id) || node.id === state.knowledgeSelectedId || children.some((child) => child.id === state.knowledgeSelectedId); + + item.className = "knowledge-tree-item"; + item.style.setProperty("--depth", String(depth)); + row.className = "knowledge-tree-row"; + row.dataset.active = String(node.id === state.knowledgeSelectedId); + row.dataset.disabled = String(node.enabled === false); + select.className = "knowledge-tree-select"; + select.type = "button"; + icon.className = "knowledge-tree-icon"; + icon.textContent = hasChildren ? (isOpen ? "▾" : "›") : node.useContent === false ? "G" : "A"; + copy.className = "knowledge-tree-copy"; + title.className = "knowledge-tree-title"; + title.textContent = node.title || node.id; + meta.className = "knowledge-tree-meta"; + meta.textContent = node.useContent === false ? "группа" : hasChildren ? "страница + ветка" : "статья"; + copy.append(title, meta); + select.append(icon, copy); + select.addEventListener("click", () => { + if (hasChildren) setKnowledgeNodeOpen(node.id, !isOpen); + selectKnowledgeNode(node.id); + }); + + actions.className = "knowledge-tree-actions"; + [ + ["+", "Добавить дочерний материал", () => addKnowledgeNode(node.id)], + ["↑", "Поднять", () => moveKnowledgeNode(node.id, -1)], + ["↓", "Опустить", () => moveKnowledgeNode(node.id, 1)], + [FINDER_TRASH_ICON_SVG, "Удалить", () => deleteKnowledgeNode(node.id)], + ].forEach(([symbol, titleText, handler]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "knowledge-tree-action"; + button.title = titleText; + button.setAttribute("aria-label", titleText); + if (titleText === "Удалить") button.innerHTML = symbol; + else button.textContent = symbol; + button.addEventListener("click", (event) => { + event.stopPropagation(); + Promise.resolve(handler()).catch((error) => setStatus(`Ошибка дерева: ${error.message}`)); + }); + actions.append(button); + }); + + row.append(select, actions); + item.append(row); + + if (hasChildren && isOpen) { + const childList = document.createElement("div"); + childList.className = "knowledge-tree-children"; + children.forEach((child) => childList.append(renderKnowledgeTreeItem(child, depth + 1))); + item.append(childList); + } + + return item; +} + +function renderKnowledgeRootItem() { + const item = document.createElement("div"); + const row = document.createElement("div"); + const select = document.createElement("button"); + const icon = document.createElement("span"); + const copy = document.createElement("span"); + const title = document.createElement("span"); + const meta = document.createElement("span"); + const actions = document.createElement("span"); + const add = document.createElement("button"); + + item.className = "knowledge-tree-item knowledge-root-item"; + item.style.setProperty("--depth", "0"); + row.className = "knowledge-tree-row"; + row.dataset.active = String(isKnowledgeRootSelected()); + row.dataset.disabled = "false"; + select.className = "knowledge-tree-select"; + select.type = "button"; + icon.className = "knowledge-tree-icon"; + icon.textContent = "K"; + copy.className = "knowledge-tree-copy"; + title.className = "knowledge-tree-title"; + title.textContent = knowledgeRootTitle(); + meta.className = "knowledge-tree-meta"; + meta.textContent = "/knowledge/ главная"; + copy.append(title, meta); + select.append(icon, copy); + select.addEventListener("click", () => selectKnowledgeNode(KNOWLEDGE_ROOT_ID)); + + actions.className = "knowledge-tree-actions"; + add.type = "button"; + add.className = "knowledge-tree-action"; + add.title = "Добавить раздел в корень"; + add.setAttribute("aria-label", "Добавить раздел в корень"); + add.textContent = "+"; + add.addEventListener("click", (event) => { + event.stopPropagation(); + addKnowledgeNode(null); + }); + actions.append(add); + + row.append(select, actions); + item.append(row); + return item; +} + +function renderKnowledgeRegions() { + el.regions.innerHTML = ""; + const group = document.createElement("section"); + const title = document.createElement("h2"); + const root = document.createElement("div"); + + group.className = "region-group knowledge-region"; + title.className = "region-title"; + title.textContent = "База знаний"; + root.className = "knowledge-tree"; + root.append(renderKnowledgeRootItem()); + + if (!knowledgeTree().length) { + const empty = document.createElement("div"); + empty.className = "empty-note knowledge-empty-tree"; + empty.textContent = "Дерево пустое. Нажмите плюс сверху, чтобы добавить первый раздел."; + root.append(empty); + } else { + knowledgeTree().forEach((node) => root.append(renderKnowledgeTreeItem(node))); + } + + group.append(title, root); + el.regions.append(group); +} + +function updateKnowledgeNodeField(node, path, value, message = "Материал базы знаний обновлён.") { + setByPath(node, path, value); + markDirty(message); +} + +function createKnowledgeInput({ node, path, label, kind = "text", wide = false, placeholder = "" }) { + const field = document.createElement("label"); + const labelRow = document.createElement("span"); + const labelText = document.createElement("span"); + const kindLabel = document.createElement("span"); + const input = kind === "textarea" ? document.createElement("textarea") : document.createElement("input"); + const pathLabel = document.createElement("span"); + + field.className = `field-control${wide || kind === "textarea" ? " wide" : ""}`; + labelRow.className = "field-label-row"; + labelText.className = "field-label-text"; + labelText.textContent = label; + kindLabel.className = "field-kind"; + kindLabel.textContent = kind; + input.value = getByPath(node, path) ?? ""; + input.placeholder = placeholder; + input.dataset.knowledgePath = path; + if (input.tagName === "INPUT") { + input.type = kind === "url" ? "url" : "text"; + input.autocomplete = "off"; + } + input.addEventListener("input", () => { + updateKnowledgeNodeField(node, path, input.value); + if (path === "title" || path === "settings.navTitle") renderKnowledgeRegions(); + }); + pathLabel.className = "field-path"; + pathLabel.textContent = path; + labelRow.append(labelText, kindLabel); + field.append(labelRow, input, pathLabel); + return field; +} + +function createKnowledgeCheckbox({ node, path, label, checkedDefault = true }) { + const field = document.createElement("label"); + const labelRow = document.createElement("span"); + const labelText = document.createElement("span"); + const kindLabel = document.createElement("span"); + const input = document.createElement("input"); + const pathLabel = document.createElement("span"); + + field.className = "field-control boolean-field"; + labelRow.className = "field-label-row"; + labelText.className = "field-label-text"; + labelText.textContent = label; + kindLabel.className = "field-kind"; + kindLabel.textContent = "boolean"; + input.type = "checkbox"; + const value = getByPath(node, path); + input.checked = value == null ? checkedDefault : value !== false; + input.addEventListener("change", () => { + updateKnowledgeNodeField(node, path, input.checked); + renderKnowledgeEditor(); + renderKnowledgeRegions(); + }); + pathLabel.className = "field-path"; + pathLabel.textContent = path; + labelRow.append(labelText, kindLabel); + field.append(labelRow, input, pathLabel); + return field; +} + +function knowledgeSelectionNodeInsideEditor(editor, node) { + if (!editor || !node) return false; + const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + return Boolean(element && editor.contains(element)); +} + +function saveKnowledgeEditorSelection() { + const selection = window.getSelection(); + if (!selection?.rangeCount) return; + const editor = el.knowledgeForm.querySelector("[data-knowledge-richtext]"); + if (!knowledgeSelectionNodeInsideEditor(editor, selection.anchorNode) || !knowledgeSelectionNodeInsideEditor(editor, selection.focusNode)) return; + state.knowledgeSavedSelection = selection.getRangeAt(0).cloneRange(); +} + +function restoreKnowledgeEditorSelection(editor) { + editor.focus(); + let range = state.knowledgeSavedSelection; + if (!range || !knowledgeSelectionNodeInsideEditor(editor, range.commonAncestorContainer)) { + range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + state.knowledgeSavedSelection = range.cloneRange(); + } + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + return range; +} + +function syncKnowledgeRichText(node, editor) { + setByPath(node, editor.dataset.knowledgeBodyPath || "bodyHtml", editor.innerHTML.trim()); + markDirty("Текст статьи обновлён."); +} + +function execKnowledgeCommand(editor, node, command, value = null) { + restoreKnowledgeEditorSelection(editor); + document.execCommand(command, false, value); + syncKnowledgeRichText(node, editor); +} + +function insertKnowledgeHtml(editor, node, html) { + restoreKnowledgeEditorSelection(editor); + document.execCommand("insertHTML", false, html); + saveKnowledgeEditorSelection(); + syncKnowledgeRichText(node, editor); +} + +function escapeKnowledgeShortcodeAttr(value) { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim() + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("[", "(") + .replaceAll("]", ")"); +} + +function normalizeKnowledgeLandingTarget(value) { + return String(value || "") + .trim() + .replace(/^#+/, "") + .replace(/^index\.html#/i, "") + .replace(/^\/?#/, ""); +} + +function normalizeKnowledgeButtonLink(value) { + return (String(value ?? "").trim() || "/").replace(/^\/+(https?:\/\/)/i, "$1").replace(/^\/+((?:mailto|tel):)/i, "$1"); +} + +function knowledgeButtonShortcode({ name, link, target, blockId }) { + const safeName = escapeKnowledgeShortcodeAttr(name || "Посмотреть"); + const safeLink = escapeKnowledgeShortcodeAttr(normalizeKnowledgeButtonLink(link)); + const safeTarget = normalizeKnowledgeLandingTarget(target); + const safeBlockId = String(blockId || "").trim(); + const blockPart = safeBlockId ? ` block="${escapeKnowledgeShortcodeAttr(safeBlockId)}"` : ""; + const targetPart = safeTarget ? ` target="${escapeKnowledgeShortcodeAttr(safeTarget)}"` : ""; + return `[button name="${safeName}" link="${safeLink}"${blockPart}${targetPart}]`; +} + +function renderKnowledgeButtonTargetOptions() { + if (!el.knowledgeButtonTargetOptions) return; + + el.knowledgeButtonTargetOptions.innerHTML = ""; + const added = new Set(); + + for (const { id, label, target } of landingBlockTargets()) { + const targetKey = target.toLowerCase(); + if (!added.has(targetKey)) { + const option = document.createElement("option"); + option.value = target; + option.label = `${label} / ${id}`; + el.knowledgeButtonTargetOptions.append(option); + added.add(targetKey); + } + + const idKey = String(id || "").toLowerCase(); + if (id && !added.has(idKey)) { + const option = document.createElement("option"); + option.value = id; + option.label = `${label} / block id`; + el.knowledgeButtonTargetOptions.append(option); + added.add(idKey); + } + } +} + +function setKnowledgeButtonError(message = "") { + if (el.knowledgeButtonError) el.knowledgeButtonError.textContent = message; +} + +function closeKnowledgeButtonModal(value = null) { + const resolve = state.knowledgeButtonResolve; + + state.knowledgeButtonResolve = null; + el.knowledgeButtonModal.classList.add("hidden"); + setKnowledgeButtonError(""); + if (resolve) resolve(value); +} + +function confirmKnowledgeButtonModal() { + const name = el.knowledgeButtonName.value.trim(); + if (!name) { + setKnowledgeButtonError("Название кнопки обязательно."); + el.knowledgeButtonName.focus(); + return; + } + + const link = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value); + el.knowledgeButtonLink.value = link; + closeKnowledgeButtonModal({ + name, + link, + targetInput: el.knowledgeButtonTarget.value.trim(), + }); +} + +function requestKnowledgeButtonInput({ name = "Посмотреть демо", link = "/", target = "" } = {}) { + if (state.knowledgeButtonResolve) closeKnowledgeButtonModal(null); + + return new Promise((resolve) => { + state.knowledgeButtonResolve = resolve; + renderKnowledgeButtonTargetOptions(); + setKnowledgeButtonError(""); + el.knowledgeButtonName.value = name; + el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(link); + el.knowledgeButtonTarget.value = normalizeKnowledgeLandingTarget(target); + el.knowledgeButtonModal.classList.remove("hidden"); + el.knowledgeButtonName.focus(); + el.knowledgeButtonName.select(); + }); +} + +function openKnowledgeMediaPicker(editor, node) { + saveKnowledgeEditorSelection(); + const fakeInput = { value: "" }; + const fakeText = { textContent: "", title: "" }; + const fakePreview = document.createElement("div"); + const fakeHint = document.createElement("span"); + const fakeError = document.createElement("span"); + const fakeUrlInput = { value: "" }; + openMediaLibrary({ + block: { id: node.id || "knowledge" }, + editableField: { path: "content.bodyHtml", label: "Изображение статьи" }, + hiddenInput: fakeInput, + urlInput: fakeUrlInput, + fileName: fakeText, + preview: fakePreview, + hint: fakeHint, + error: fakeError, + setSource() {}, + onSelect(url) { + const safeUrl = String(url || ""); + if (!safeUrl) return; + insertKnowledgeHtml(editor, node, `${escapeHtml(knowledgeButtonShortcode({ name: values.name, link: values.link, target, blockId }))}
`, + ); + }], + ["Image", "Изображение", () => openKnowledgeMediaPicker(editor, node)], + ]; + + buttons.forEach(([label, title, action]) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", action); + toolbar.append(button); + }); + + return toolbar; +} + +function renderKnowledgeEditor() { + const isRoot = isKnowledgeRootSelected(); + const node = selectedKnowledgeNode(); + el.empty.classList.add("hidden"); + el.form.classList.add("hidden"); + el.knowledgeForm.classList.remove("hidden"); + el.knowledgeForm.innerHTML = ""; + + if (!state.knowledge) { + el.knowledgeForm.innerHTML = 'Нажмите плюс в левом верхнем углу, чтобы создать первый раздел.
"; + el.knowledgeForm.append(empty); + return; + } + + state.knowledge.settings ||= {}; + const bodyPath = isRoot ? "introHtml" : "bodyHtml"; + const match = isRoot ? null : findKnowledgeNode(node.id); + const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний"; + el.selectedRegion.textContent = isRoot + ? `NODE.DC / ${knowledgeRootTitle()} / /knowledge/` + : `NODE.DC / ${state.knowledge.title || "База знаний"} / ${parentTitle}`; + el.selectedTitle.textContent = isRoot ? knowledgeRootTitle() : node.title || "Материал"; + + const metaPanel = document.createElement("section"); + const metaHead = document.createElement("div"); + const metaTitle = document.createElement("h3"); + const metaKicker = document.createElement("div"); + const grid = document.createElement("div"); + metaPanel.className = "content-panel"; + metaHead.className = "panel-head"; + metaKicker.className = "section-kicker"; + metaKicker.textContent = isRoot ? "Корневая страница" : "Узел дерева"; + metaTitle.textContent = isRoot ? "Настройки /knowledge/" : "Параметры материала"; + metaHead.append(Object.assign(document.createElement("div"), { innerHTML: `${metaKicker.outerHTML}