SEO zero ready

This commit is contained in:
dcconstructions
2026-06-27 19:20:56 +03:00
parent e9c0dd56c2
commit e1daef3872
76 changed files with 1372 additions and 4364 deletions
+90 -5
View File
@@ -795,6 +795,13 @@ body[data-file-picker-active="true"] .editor {
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
body[data-admin-mode="seo"] #add-block,
body[data-admin-mode="seo"] .block-meta-card,
body[data-admin-mode="seo"] .structure-card,
body[data-admin-mode="seo"] .json-details {
display: none;
}
.hidden {
display: none !important;
}
@@ -802,7 +809,8 @@ body[data-file-picker-active="true"] .editor {
.settings-card,
.content-panel,
.json-details,
.content-group {
.content-group,
.seo-diagnostics {
border-radius: var(--launcher-radius-card);
background: rgba(255, 255, 255, 0.045);
}
@@ -930,6 +938,72 @@ body[data-file-picker-active="true"] .editor {
white-space: nowrap;
}
.seo-diagnostics {
display: grid;
gap: 1rem;
padding: 1rem;
}
.seo-metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.seo-metric {
display: grid;
gap: 0.35rem;
min-width: 0;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.055);
padding: 0.82rem;
}
.seo-metric span {
overflow: hidden;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 850;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.seo-metric strong {
overflow: hidden;
color: var(--text-primary);
font-size: 0.88rem;
font-weight: 850;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.seo-metric[data-status="good"] strong,
.seo-field-meter[data-status="good"] {
color: #bcf4d1;
}
.seo-metric[data-status="warn"] strong,
.seo-field-meter[data-status="warn"] {
color: #f3d28b;
}
.seo-metric[data-status="bad"] strong,
.seo-field-meter[data-status="bad"] {
color: #ffb7b7;
}
.seo-field-meter {
overflow: hidden;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 720;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
}
.boolean-field {
align-content: start;
}
@@ -1313,7 +1387,8 @@ body[data-file-picker-active="true"] .editor {
}
input,
textarea {
textarea,
select {
width: 100%;
border: 0;
border-radius: 1rem;
@@ -1323,7 +1398,8 @@ textarea {
}
input:focus,
textarea:focus {
textarea:focus,
select:focus {
background: rgba(255, 255, 255, 0.1);
box-shadow: none;
}
@@ -1333,6 +1409,10 @@ textarea {
resize: vertical;
}
select {
appearance: none;
}
.toggle-row {
justify-content: flex-start;
gap: 0.65rem;
@@ -2473,6 +2553,10 @@ textarea {
grid-template-columns: 1fr;
}
.seo-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.media-preview-panel {
grid-template-columns: 9rem minmax(0, 1fr);
grid-template-rows: auto auto;
@@ -2500,7 +2584,7 @@ textarea {
.nodedc-expanded-nav-group {
display: grid;
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
}
@@ -2519,7 +2603,8 @@ textarea {
}
.field-grid,
.group-grid {
.group-grid,
.seo-metric-grid {
grid-template-columns: 1fr;
}
+387 -21
View File
@@ -34,7 +34,12 @@ function readLastMediaDirectory() {
return String(readAdminLayoutState().lastMediaDirectory || "").replace(/^\/+/, "");
}
function readAdminMode() {
return readAdminLayoutState().mode === "seo" ? "seo" : "content";
}
const state = {
mode: readAdminMode(),
page: null,
templates: [],
selected: null,
@@ -69,7 +74,78 @@ const state = {
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"),
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"),
@@ -143,7 +219,7 @@ const STATIC_FALLBACK = {
enabled: true,
content: {
navigation: {
logoSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
logoSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
@@ -152,7 +228,7 @@ const STATIC_FALLBACK = {
{
enabled: true,
label: "Документация",
href: "https://docs.ssscript.app/",
href: "https://nodedc.dctouch.ru/",
target: "",
linkMode: "href",
linkTarget: "_blank",
@@ -191,9 +267,9 @@ const STATIC_FALLBACK = {
href: "javascript:void(0)",
linkTarget: "",
targetId: "app-data",
iconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
iconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
iconAlt: "Иконка NODE.DC",
className: "dock-icon ssscript",
className: "dock-icon nodedc-app",
iconClass: "logo",
dividerBefore: false,
},
@@ -203,7 +279,7 @@ const STATIC_FALLBACK = {
href: "javascript:void(0)",
linkTarget: "",
targetId: "",
iconSrc: "./assets/webflow/images/69a9ae818a76c773dc3cf7bc_trash.avif",
iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif",
iconAlt: "Белая иконка корзины.",
className: "dock-icon no-bg",
iconClass: "dock-app-img trash",
@@ -214,11 +290,11 @@ const STATIC_FALLBACK = {
},
assets: {
webglModelSrc: "./assets/custom/icon.glb",
logoPng: "./assets/custom/logo.png",
logoPng: "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png",
logoPngAlt: "Логотип NODE.DC",
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
appIconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg",
appIconAlt: "Иконка NODE.DC",
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=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",
@@ -272,7 +348,7 @@ const STATIC_FALLBACK = {
targetId: "",
iconSrc: "",
iconAlt: "",
className: "dock-icon ssscript",
className: "dock-icon nodedc-app",
iconClass: "",
dividerBefore: false,
},
@@ -322,7 +398,7 @@ const GROUP_COPY = {
Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.",
Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.",
"Один клик": "Подблок про быстрое добавление и настройку компонентов.",
MCP: "Подблок про MCP-сервер и контекст Webflow.",
MCP: "Подблок про MCP-сервер и контекст NODE.DC.",
"Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.",
Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.",
FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.",
@@ -354,7 +430,7 @@ const MEDIA_ROOT_FILTERS = [
["assets", "Assets"],
["assets/uploads", "Uploads"],
["assets/media", "Media"],
["assets/webflow/images", "Webflow"],
["assets/site/images", "NODE.DC"],
["assets/custom", "Custom"],
];
const FINDER_TRASH_ICON_SVG =
@@ -459,6 +535,276 @@ function setByPath(source, path, nextValue) {
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" ? "seo" : "content";
state.mode = mode;
updateAdminLayoutState({ mode });
if (state.page) {
renderAll();
} else {
updateAdminModeSurface();
}
}
function updateAdminModeSurface() {
const isSeo = state.mode === "seo";
document.body.dataset.adminMode = state.mode;
if (el.modeContent) el.modeContent.dataset.active = String(!isSeo);
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.panelNote) {
el.panelNote.textContent = isSeo
? "Поля готовят страницу и секции к семантической настройке и дальнейшей автоматизации."
: "Поля сгруппированы по смысловым подблокам верстки.";
}
}
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) {
@@ -541,7 +887,7 @@ function createDefaultDockItems(dock = {}, assets = {}) {
targetId: dock.appTarget || "app-data",
iconSrc: assets.appIconSrc || STATIC_FALLBACK.content.assets.appIconSrc,
iconAlt: assets.appIconAlt || STATIC_FALLBACK.content.assets.appIconAlt,
className: "dock-icon ssscript",
className: "dock-icon nodedc-app",
iconClass: "logo",
dividerBefore: false,
},
@@ -551,7 +897,7 @@ function createDefaultDockItems(dock = {}, assets = {}) {
href: "javascript:void(0)",
linkTarget: "",
targetId: "",
iconSrc: "./assets/webflow/images/69a9ae818a76c773dc3cf7bc_trash.avif",
iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif",
iconAlt: dock.trashAlt || "Белая иконка корзины.",
className: "dock-icon no-bg",
iconClass: "dock-app-img trash",
@@ -578,7 +924,7 @@ function normalizeDock(dock, assets = {}) {
targetId: item.targetId ?? fallback.targetId ?? "",
iconSrc: hasOwn("iconSrc") ? item.iconSrc : fallback.iconSrc,
iconAlt: hasOwn("iconAlt") ? item.iconAlt : item.alt || fallback.iconAlt,
className: item.className || fallback.className || "dock-icon ssscript",
className: item.className || fallback.className || "dock-icon nodedc-app",
iconClass: hasOwn("iconClass") ? item.iconClass : fallback.iconClass || "logo",
dividerBefore: Boolean(item.dividerBefore),
};
@@ -1241,6 +1587,10 @@ function addBlockFromTemplate(templateKey) {
block.id = uniqueBlockId(block.id);
block.enabled = true;
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();
@@ -1268,23 +1618,29 @@ function renderEditorError(error) {
}
function renderEditor() {
updateAdminModeSurface();
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 = "Контентный слой";
el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой";
return;
}
el.empty.classList.add("hidden");
el.form.classList.remove("hidden");
el.form.dataset.static = String(isStatic);
el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`;
el.selectedTitle.textContent = block.adminLabel || block.id;
el.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 || "";
@@ -1294,15 +1650,19 @@ function renderEditor() {
el.anchor.disabled = isStatic;
el.enabled.disabled = isStatic;
el.enabled.closest(".toggle-row").hidden = isStatic;
setStructuralControlsDisabled(isStatic);
setStructuralControlsDisabled(isStatic || isSeo);
try {
renderContentFields(block);
if (isSeo) {
renderSeoFields(block);
} else {
renderContentFields(block);
}
} catch (error) {
renderEditorError(error);
}
el.json.value = JSON.stringify(block, null, 2);
el.json.value = JSON.stringify(editorJsonPayload(block), null, 2);
}
function renderAll() {
@@ -3703,7 +4063,9 @@ function syncBlockFromJson() {
try {
const parsed = JSON.parse(el.json.value);
if (isStaticSelection()) {
if (state.mode === "seo" && isStaticSelection()) {
state.page.seo = parsed;
} else if (isStaticSelection()) {
state.page.staticElements = parsed;
} else {
const region = activeRegion();
@@ -3732,6 +4094,7 @@ async function loadPage() {
state.templates = await templatesResponse.json();
ensureStaticElements();
ensureSectionsRegion();
ensureSeoModel();
state.selected = { kind: "static" };
renderAll();
setDirty(false);
@@ -3845,6 +4208,9 @@ for (const input of [el.id, el.label, el.anchor, el.enabled]) {
input.addEventListener("change", syncBlockFromFields);
}
el.modeContent?.addEventListener("click", () => setAdminMode("content"));
el.modeSeo?.addEventListener("click", () => setAdminMode("seo"));
el.json.addEventListener("blur", () => {
if (state.filePickerActive || state.mediaUploadActive) {
recoverEditorSurface();
+10 -13
View File
@@ -18,17 +18,14 @@
</div>
<div class="nodedc-expanded-toolbar-center">
<span class="nodedc-expanded-workspace-button" aria-hidden="true">
<img src="../assets/custom/logo.png" alt="" class="nodedc-expanded-workspace-avatar" />
<img src="../assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png" alt="" class="nodedc-expanded-workspace-avatar" />
</span>
<nav class="nodedc-expanded-nav-group" aria-label="Навигация админки">
<a class="nodedc-expanded-nav-button" href="/" target="_blank" data-active="false">
<span>Витрина</span>
</a>
<button class="nodedc-expanded-nav-button" type="button" data-active="true">
<button id="admin-mode-content" class="nodedc-expanded-nav-button" type="button" data-active="true">
<span>Администрирование</span>
</button>
<button class="nodedc-expanded-nav-button" type="button" data-active="false">
<span>Платформа</span>
<button id="admin-mode-seo" class="nodedc-expanded-nav-button" type="button" data-active="false">
<span>SEO</span>
</button>
</nav>
</div>
@@ -53,14 +50,14 @@
<div class="sidebar-head">
<div>
<div class="section-kicker">NODE.DC</div>
<h1>Администрирование</h1>
<h1 id="sidebar-title">Администрирование</h1>
</div>
<button id="add-block" class="admin-panel-close add-block-button" type="button" aria-label="Добавить блок">+</button>
<div class="workspace-card admin-panel-client-select">
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
<div>
<div class="workspace-name">NODE.DC</div>
<div class="workspace-role">Администратор сайта</div>
<div id="workspace-role" class="workspace-role">Администратор сайта</div>
</div>
</div>
</div>
@@ -70,7 +67,7 @@
<section class="editor">
<header class="editor-head">
<div>
<h1>Настройки сайта</h1>
<h1 id="editor-heading">Настройки сайта</h1>
<div id="selected-region" class="breadcrumb">NODE.DC / Выберите блок</div>
<h2 id="selected-title">Контентный слой</h2>
</div>
@@ -160,10 +157,10 @@
<section class="content-panel">
<div class="panel-head">
<div>
<div class="section-kicker">Контент</div>
<h3>Редактируемые поля</h3>
<div id="content-panel-kicker" class="section-kicker">Контент</div>
<h3 id="content-panel-title">Редактируемые поля</h3>
</div>
<span class="panel-note">Поля сгруппированы по смысловым подблокам верстки.</span>
<span id="content-panel-note" class="panel-note">Поля сгруппированы по смысловым подблокам верстки.</span>
</div>
<div id="content-fields" class="content-fields"></div>
</section>